Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld bc5b0056a7 Let users hide entities from the maintenance page
Battery and problem sensors land on Maintenance purely by device class,
so a car's target charge level showed up with no way out short of hiding
the entity everywhere.

Store a hidden_entities list in maintenance frontend system data and let
the resolver take it, so the Home dashboard summary badge, the overview
check and the area views drop the same entities as the page. The pencil
enters an inline edit mode: the page regenerates with every tile wrapped
in a new entity-visibility card that dims hidden tiles with a dotted
border and toggles them on tap, saving at once.

Saves go through a queue shared by all panel instances, and a panel
loads its config only after that queue drains, so leaving and
reopening the page while a save is in flight cannot reload a snapshot
that lacks it. A failed save shows a toast and reloads, unless a newer
save is already queued on top of it.

Every tap regenerates the view, which replaces the focused tile. The
wrapper card remembers that its button fired while it had focus and the
recreated wrapper for the same entity focuses its button, so keyboard
users stay where they were.
2026-11-04 20:32:41 +01:00
Maarten Lakerveld 640e4e2f94 Resolve security and maintenance entities through one helper per page (#55609)
The security page, the maintenance page, the Home summary badges and
the Home overview each rebuilt the same filter functions and ran
findEntities over every state. Add findFilteredEntities, which takes
the states and registries as one object, and a findSecurityEntities
and findMaintenanceEntities wrapper so every consumer resolves a
page's entity set the same way. No behaviour change.
2026-11-04 20:32:36 +01:00
renovate[bot] 6670139c02 Update dependency @bundle-stats/plugin-webpack-filter to v4.23.0 (#55642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 20:13:39 +02:00
Erwin DounaandCopilot Autofix powered by AI 93eb44b041 Offer log download after disabling debug logging (#55621)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-04 16:49:28 +01:00
Joost LekkerkerkerandClaude Opus 5.5 ba6067f048 Label marketplace open issue count as issues and pull requests (#55635)
* Label marketplace open issue count as issues and pull requests

The GitHub open issues count includes pull requests. Rename the
translation keys and update the English strings so the label matches
the number shown.

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

* Update marketplace tests for the renamed open issues keys

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 17:22:53 +02:00
renovate[bot] cd866a497a Update dependency globals to v17.14.0 (#55633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 17:20:30 +02:00
98a8483426 onboarding-location migrate list (#55467)
* onboarding-location migrate list

* Apply batched suggestions from code review

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

* fix tests and review

---------

Co-authored-by: Aidan Timson <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-11-04 15:39:56 +02:00
33af67bafe Respect safe area insets on the onboarding page (#55623)
* Respect safe area insets on the onboarding page

* Update src/html/onboarding.html.template

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

* Update src/html/onboarding.html.template

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Wendelin <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-11-04 12:58:51 +00:00
Bram KragtenandClaude Opus 5.5 027d9578ec Match the entity filter in the states tool to the entity column (#55622)
The entity filter matched the full name and the friendly name, so typing a
device name returned entities whose entity column showed no match. It now
matches the entity ID and the name the entity column shows. When the device
column is hidden, it still matches the full name.

Fixes #55451

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:54:05 +02:00
Bram KragtenandClaude Opus 5.5 5fef8f3a68 Refetch the cached lovelace dashboard after it changed elsewhere (#55624)
The "lovelace" panel is cached by the panel resolver, so it should stay
subscribed to config updates while the user is on another page. Since the
urlPath getter returns "lovelace" instead of null, the null check in
disconnectedCallback always unsubscribed it, and nothing resubscribed on
reconnect. Changes saved from another page, like adding a device section
from the device page, only showed after a browser refresh.

Also reset the refetch flag once used, so returning to the dashboard
without a change doesn't refetch the config again, and let a pending
refetch take precedence over the locale rebuild, which would otherwise
rebuild from the stale config.

Fixes #55440

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:53:35 +02:00
Wendelin 18e65c1940 Show a loading skeleton while the card editor loads (#55625) 2026-11-04 11:22:55 +00:00
29906d6f83 Let core work out what target exclusions take out (#55585)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-04 11:00:40 +01:00
WendelinandClaude Opus 5.5 952c227370 Migrate the new automation dialog and zones list (#55593)
* Migrate the new automation dialog and zones list from ha-list

Moves the new automation and script dialog and the zones list from the
mwc-based ha-list and ha-list-item to ha-list-base and
ha-list-item-button, handling clicks instead of request-selected.

- Render the get blueprints link as a list item button with an href
  inside the blueprints list
- Keep the zone edit buttons in the row's end slot, as their handlers
  already stop the click from reaching the row
- Drop the request-selected handling left in the more info dialog and
  the webhook trigger, where nothing fires it anymore
- Remove the now unused request-selected helper
- Point the automation e2e helper at the new list items

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Pass the blueprint path and zone id as data attributes

Both are plain strings, so data attributes avoid untyped ad hoc
properties on the list items and their any casts in the handlers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Drop the unused entityId property from the zone edit button

Nothing reads it: the edit handler only checks noEdit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Drop the redundant display block on the zones list

ha-list-base is already a block element.

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

* Hide the home zone edit button on narrow screens

The old list item hid its meta slot for the home zone on narrow
screens, where clicking the row already edits it. The new list item
has no such switch, so leave the button out instead.

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

* Type the list item click handlers with their current target

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

---------

Co-authored-by: Claude <[email protected]>
2026-11-04 11:43:24 +02:00
pcan08andClaude Opus 5.5 173c60e57b Let users choose buttons or a slider in the fan speed tile card feature (#55266)
* Allow fans with up to 11 speeds to show speed buttons

Keep showing buttons by default up to 6 speeds, and add helpers to
resolve the speed style (buttons or slider) for a fan.

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

* Add a switch between buttons and slider for fan speed in more-info

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

* Add a style option to the fan speed card feature

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

* Add fans with more speeds to the gallery

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

* Use contexts in the fan speed card feature editor

Read the fan state and localize from contexts instead of the full hass
object, so the editor only updates when this fan or the language changes.

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

* Address review feedback on fan speed style

- Only show the "too many speeds" info in the feature editor when the
  fan has too many speeds for buttons, not for an off/on fan.
- Reuse the numeric input style labels and align the more-info switch
  labels with covers.
- Show fractional steps in round-trip test names.

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

* Keep buttons for off/on fans whatever style is requested

Fans that do not offer a style choice now always resolve to their
default style, so an off/on fan configured with the slider style no
longer gets stuck on a slider with a disabled selector.

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

* Keep dense fan speed buttons from overflowing on short screens

Give dense vertical speed buttons a minimum height based on the number
of segments, so they no longer overflow into the mode switch when the
viewport is short.

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

* Remove the fan speed style switch from more-info

Keep this change to the card feature, as asked in review. The more-info
dialog goes back to its previous behavior: buttons up to 6 positions,
a slider above.

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

* Show a row of segments for the fan speed buttons style

The buttons picture in the fan speed feature editor showed the numeric
input stepper. Use a new picture of a segmented row instead.

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

* Fall back to a slider when fan speed buttons do not fit

Measure the fan speed feature width like the favorite features and show
the slider when the buttons would be too narrow for their labels. Off
and 6 speeds still fit as buttons in a half-width card, while a fan with
10 speeds no longer cuts its last label. The editor now explains this
below the style option.

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

* Clarify fan speed feature examples and helper logic

- Explain the button minimum width next to the constants instead of in
  the test.
- Read the disabled state from the schema for the editor helper text.
- Describe the default slider and the width fallback in the gallery
  headings.

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

* Use a 24px minimum for fan speed buttons

Buttons in card features have no gap between them, so each one must be
at least 24px wide to meet the WCAG 2.2 AA target size. This also fits
two-digit speeds, so a single minimum replaces the previous pair.

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

* Refine tmp fan entity and config in gallery

* Keep the fan speed button minimum width in the card feature

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 11:36:10 +02:00
Paul Bottein ef134330f3 Add an option to include pre-releases in Marketplace repositories (#55567) 2026-11-04 09:24:07 +00:00
WendelinandClaude Opus 5.5 c75386ab33 Add descriptive aria-labels to picker filter chips (#55592)
* Give picker section filter chips a descriptive aria-label

Screen readers announced the section chips in quick search and the
target picker only by their visible text (e.g. "Navigate"), which gives
no hint that the button filters the results. Label them as
"Filter: <section>" instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eFvrHAAoLcdEeqDAUg

* Fix fallback for section label localization in ha-picker-combo-box

---------

Co-authored-by: Claude <[email protected]>
2026-11-04 10:16:45 +01:00
Franck Nijhof 61b433f912 Add Marketplace download source menu item for the China mirrors (#55615) 2026-11-04 10:16:34 +01:00
renovate[bot] 16ed504c66 Update formatjs (major) (#55619)
Update formatjs

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 06:37:58 +00:00
renovate[bot] e9550ffe52 Update formatjs (#55618)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 08:28:13 +02:00
Aidan Timson 2c6cf8e45b Fix the demo embed for the contributors site (#55596) 2026-11-03 15:49:56 +01:00
0dcfc247a7 Add a navigate button to the map overview (#55600)
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-03 15:43:08 +01:00
Maarten Lakerveld 2ab1f187c5 Add requested neon map theme (#55603) 2026-11-03 15:39:25 +01:00
56d6f1c571 Migrate last checkbox list (#55453)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-11-03 15:35:51 +01:00
Aidan Timson f169ae79f5 Add a loading state to the generic picker (#55561) 2026-11-03 15:17:47 +01:00
Josef Zweck 599089a2d3 WTH: Show when long-lived access tokens were last used (#55589) 2026-11-03 15:10:45 +01:00
Maarten Lakerveld 2af451338f Stop recomputing active system domains on every state change in the add element dialog (#55586) 2026-11-03 15:00:45 +01:00
karwosts 2ba9a1fc61 Add segments editor to bar gauge (#55601) 2026-11-03 14:51:26 +01:00
dependabot[bot] 96e525f7a3 Bump postcss-selector-parser from 7.1.5 to 7.1.7 (#55598)
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 7.1.5 to 7.1.7.
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/7.1.5...7.1.7)

---
updated-dependencies:
- dependency-name: postcss-selector-parser
  dependency-version: 7.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-03 12:31:49 +00:00
WendelinandClaude c42dac197f Fix Copilot code review failing on the setup steps (#55597)
Inline setup steps in the Copilot setup workflow

Copilot runs copilot-setup-steps.yml in a context that can't resolve
`$/` self-repository actions, so Copilot code review fails with
"Unable to resolve self-reference '$/'". Inline the setup action's
steps so the workflow only uses pinned remote actions; the actions
lockfile entries stay the same.


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

Co-authored-by: Claude <[email protected]>
2026-11-03 12:20:56 +00:00
Alexander Hass 8fbae6346d Fix README images and links with a relative path in the Marketplace (#55525)
* Keep relative README addresses for the Marketplace rewrite

Images and links in a README with a plain relative path, such as `images/logo.png`, were broken on the Marketplace repository page. xss empties every `src` and `href` that does not start with a scheme, `/`, `./`, `../` or `#`, so the path was gone before `rewriteHtmlUrls` could point it at the repository.

`renderMarkdown` takes `allowRelativeUrls`, which `ha-markdown-element` sets when it rewrites the addresses afterwards. Such an address of a link, image or source is kept as a path below the current one, so it can never carry a scheme, and xss checks it like any other address.

* Trim a relative README address before it gets the ./ prefix

xss trims the spaces written around an attribute value, but not encoded ones. A relative path such as `&#32;images/logo.png` therefore got the space inside the path after the ./ prefix, and the image stayed broken. The value is now decoded and trimmed first, as the browser trims the address.

* Decode a relative README address once and keep data links first

The relative address was decoded before `safeAttrValue` decoded it again, so `&#38;#105;mages` was checked and shown as `images` while the browser reads `&#105;mages`. Like `srcset`, it is now decoded once with `&amp;` for query strings, dropped when entities are left over, and written back escaped, so the browser reads the path that was checked.

Links to data URLs allowed with `allowDataUrl` are handled before relative addresses again, so they are no longer turned into paths.

* Escape a kept relative README address only once

`safeAttrValue` returns the value escaped already, so escaping its result again turned a quote in a path into `&amp;quot;`, and the browser requested another file. It is now only the check, and the decoded path is escaped once, like `srcset`.

The comments of the change are cut down to the one above the new function, as the code around has few.

* Drop relative README addresses with a scheme and reuse URL_ATTRIBUTES

A relative path never has a scheme, so an address xss empties that still has one after decoding (HTTPS://..., j:alert(1) from &#0000106avascript:) or is empty is dropped instead of being kept below ./, where it would request a path in the repository that does not exist. The worker imports URL_ATTRIBUTES from rewrite-html-urls, so the attributes it keeps and the ones the rewrite touches cannot drift apart.
2026-11-03 09:55:44 +00:00
Petar Petrov 2a69518dba Add option to combine energy sources of the same type (#55548)
* Add option to combine energy sources of the same type

Add a "Combine sources" toggle to the energy period selector menu and a
combine_sources option to the energy cards that list sources separately.
The Summary view always combines sources.

* Make combine_sources a per-card option with each card's current default

Cards render from their own combine_sources option instead of a flag on the
energy data. The power sources graph and the energy and power Sankeys default
to combined and gain a separate mode. The energy dashboard toggle now sets the
option on its cards through the strategies.

Keep stacked series and Sankey columns in the same order when switching.

* Keep usage graph series stable across modes instead of in ha-chart-base

Combined mode puts the sum on the first source's series and keeps the other
sources and the fallback series as empty series, so the series ids stay the
same and ECharts keeps the stack order. Revert the ha-chart-base guard.

* Only offer Combine sources on the energy panel

* Keep the legend entry of a source with only comparison data

* Keep the combine_sources setting on the energy collection

The collection holds the setting, unset by default and saved per collection
only when chosen, and re-publishes the data with it. Cards fall back to it
before their own default. A date picker shows the toggle only with
show_combine_sources, which the Electricity, Gas and Water views enable along
with an initial off value. The setting only applies while a source type is
configured twice.

* Only combine or split source types with two or more sources

A type with a single source keeps each card's current look in both modes.
The Electricity, Gas and Water views only show the toggle when their own
source types have duplicates.

* Keep the solar graph's forecasts from older requests or missing data
2026-11-03 11:48:14 +02:00
Aidan Timson 960bf08c84 Lock workflow actions with an actions.lock file (universe/changelog) (#55446)
* Lock workflow actions with an actions.lock file

* Run the actions lockfile check on every pull request

* Regenerate the actions lockfile after the rebase

* Pin Renovate's gh-actions-lock to the version CI verifies with
2026-11-03 10:17:08 +01:00
Aidan TimsonandClaude Fable 5.1 6c2bc65f07 Leave out README sections marked for the Marketplace to skip (#55571)
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-03 08:37:04 +00:00
Simon LamonandPetar Petrov e52547e374 dialog-helper-detail migrate list (#55466)
Co-authored-by: Petar Petrov <[email protected]>
2026-11-03 09:34:53 +01:00
WendelinandClaude Opus 5.5 7474e3e342 Reorder dashboard views by dragging their tabs (#55565)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-03 09:31:05 +01:00
puddly 8dbd1527d9 Cluster ZHA devices and adjust colors/icons (#55418)
* Add virtual parents for offline, parentless, and green power devices

* Tighter pull for virtual groups

* Merge offline with no parent

* Add hidden nodes to better space disconnected clusters

* Ignore neighbor table contributions from offline devices

* Make offline device edges 1.5x weaker

* Differentiate router color from end device color

* Switch `no parent` to `unknown parent`

* Adjust sizing

* Shrink the root symbol on mobile

* Fix tests
2026-11-03 10:26:48 +02:00
karwostsandCopilot Autofix powered by AI d9d849c843 Add segments to gauge UI (and single color select) (#55575)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-03 09:20:37 +01:00
Alexander Hass 4f73b43912 Resolve external statistics in the History panel (#55521)
* Resolve external statistics in the History panel

The History panel plots external statistics (`<domain>:<object_id>`), for example when the statistics graph card links to it, but treated them as entities everywhere else.

- The sources picker looks up an external statistic in the statistics metadata and shows its name instead of "Entity not found".
- Statistics without an entity take name and unit from their metadata, so the legend and tooltip no longer show the prettified ID without a unit.
- External statistic IDs are no longer sent to `history/stream`. Core rejects the whole subscription for an ID that is not an entity ID, which dropped the state history of every other picked entity.

* Use statistic metadata only for external statistics

The statistics of a removed entity got the unit from their metadata, but no device class. Its recent state history has the same unit and a device class, so both ended up in separate charts and the overlap was no longer trimmed. Other statistics keep a blank unit again and merge into the history line of their entity. The metadata is only fetched for external statistics.

* Reset the zoom when only external statistics remain

Removing the last entity from a zoomed view returned before the zoom was reset. The chart was rebuilt at full range, but the zoom slider kept the previous range.

* Reset the chart zoom when only external statistics are shown

Charts of external statistics stay mounted when the date range changes, because there is no state history to load. Resetting only the zoom range left the chart zoomed while the reset button was disabled. The zoom is now reset on the charts as well, and the normal path is unchanged again.

* Keep the statistics when the metadata lookup fails

A failed metadata lookup rejected the whole request, so the fetched statistics were dropped and the panel could stay on the loading spinner. External statistics are now shown under their ID instead.

* End the loading when the statistics request fails

With only external statistics selected there is no state history, so a failed statistics request left the panel on the loading spinner. A failed request now sets an empty result, guarded by the fetch ID so that an older request cannot overwrite newer data.

* Show external statistics like the statistic picker and inline the stream filter

The sources picker row of an external statistic gets the chart line icon and the source integration as context, as in ha-statistic-picker, instead of the default entity icon. The filter for the history stream has a single caller, so it is inlined in the History panel and its export and test are dropped. The comment on the blank unit of other statistics moves to the isExternalStatistic check it describes.

* Clear the previous statistics before fetching new ones in the History panel

Switching from one external statistic to another kept the chart of the previous one on screen and in the download until the new request was done, as no state history shows the loading then. The statistics are now cleared before the request, so the panel shows its loading state instead.
2026-11-03 10:14:26 +02:00
a6fb28dfef Wrap long descriptions in the entity settings (#55590)
* Wrap supporting text of entity settings rows

Long explanations such as the visibility description were truncated
with an ellipsis, especially in languages with longer translations.

Fixes #55584

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGRG4gEJgUauHeEZcB

* Update supporting-text styles for row and list items

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-03 09:07:52 +01:00
renovate[bot] d4ad00c509 Update dependency tslib to v2.9.1 (#55588)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-03 08:17:06 +02:00
renovate[bot] 2fdef6883c Update dependency @formatjs/intl-locale to v5.4.1 (#55582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 20:39:52 +01:00
Petar Petrov 598034b1d3 Keep chart tooltips inside the window (#55569)
* Keep chart tooltips inside the window

ECharts only fits tooltips into the chart, so a tooltip taller than the
chart opened above it and ran off the top of the window. Charts without
their own tooltip position now move it into the window, below the
cursor first.

The energy devices detail tooltip also lists the 10 largest devices,
largest first, and folds the rest into one "N other devices" row.

* Try both sides of the cursor and keep confined tooltips in the chart

When the window fit has to move a tooltip, try the other side of the
cursor horizontally too before clamping, so it does not cover the
hovered point. A confined tooltip now only moves within the visible part
of the chart and keeps its chart-confined spot otherwise, so it is not
clipped by containers that hide overflow.

* Keep confined tooltips on screen and skip hidden rows when folding

When the visible part of a confined chart is too small for the tooltip,
move the tooltip only as far as the window needs instead of leaving it
off screen.

Devices whose value the tooltip shows as 0 are no longer folded into the
"other devices" row, so that row never appears without the larger
devices it refers to.
2026-11-02 20:36:36 +01:00
Aidan Timson 4ad7c7a96a Add a level filter to the logs page (#55403)
* Add a level filter to the logs page

* Pass the logs level filter through My links

* Share the supported log levels and the log entry filtering

* Expand one logs filter at a time and count from the other filters
2026-11-02 20:34:57 +02:00
Petar Petrov 14f66ca105 Drop detected location details after choosing another place in onboarding (#55546)
* Drop detected location details after choosing another place in onboarding

* Reset the elevation when searching an address in onboarding

* Keep the chosen country when the browser time zone is unknown in onboarding
2026-11-02 17:01:27 +01:00
Aidan Timson 831dbeb975 Keep the system log skeleton left to right like the list (#55572) 2026-11-02 16:58:32 +01:00
Aidan Timson aecf17aae4 Show log level counts in the logs integration filter (#55402)
* Add optional count badges to the integration filter

* Show log level counts in the logs integration filter

* Label log count badges and hide them in the raw view

* Show log count badges on the integration filter's supporting line
2026-11-02 16:57:40 +01:00
renovate[bot] 037d3dad01 Update dependency @lezer/common to v1.5.5 (#55573) 2026-11-02 16:55:38 +01:00
WendelinandClaude Opus 5.5 e80c2e55b5 Migrate the remaining static lists to ha-list-base (#55560)
* Migrate the remaining static lists from ha-list

Moves the auth provider picker, the config flow menu step, the
onboarding address search results, the input select options, the
system log card, the Wi-Fi access points, the network storage mounts
and the adjust sum statistics list from the mwc-based ha-list and
ha-list-item to ha-list-base, ha-list-selectable, ha-list-item-button,
ha-list-item-option and ha-list-item-base.

- Make the onboarding address results and the Wi-Fi access points
  single-select lists, as they were activatable lists before
- Copy the picked access point into a new Wi-Fi configuration instead
  of using the access point object itself, which the form then changed
- Let the config flow menu descriptions wrap, as multiline-secondary did
- Keep the list padding, icon colors and mount icon column of the
  previous lists
- Remove unused element imports in the touched files

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Add aria-label attributes to ha-list-base components for improved accessibility

* Refactor onboarding location component to use ha-list and ha-list-item for improved structure and interactivity

* Add reload label to network mounts and update translations for storage

* Refactor storage panel to use ha-list-item-base and ha-list-item-button for improved UI consistency

---------

Co-authored-by: Claude <[email protected]>
2026-11-02 17:14:24 +02:00
renovate[bot] 581b6abdd8 Update dependency eslint to v10.14.0 (#55568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 17:12:24 +02:00
renovate[bot] 4c43c7446a Update formatjs (#55566) 2026-11-02 15:51:56 +01:00
0f73094afd Add embeddable dashboard card components to the demo (#54348)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 15:50:19 +01:00
Maarten Lakerveld 53f8e6c8cb Add an accessible name to the to-do panel list pane (#55564)
Give the to-do panel list pane an accessible name

The migrated ha-list-base pane had no label, so screen readers announced an unnamed list. Reuse the existing "To-do lists" panel title, matching how the calendar panel labels its pane list.
2026-11-02 15:49:22 +01:00
Matthias de BaatandClaude Opus 5.5 5c9fc8723c Make content width and gutter consistent with tokens (#55270)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 15:33:47 +01:00
8995004feb ha-panel-todo migrate list (#55465)
* ha-panel-todo migrate list

* Update src/panels/todo/ha-panel-todo.ts

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

* Update src/panels/todo/ha-panel-todo.ts

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

---------

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 14:31:41 +01:00
4fa5991889 Refactor bulk delete labels (#55555)
Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-02 14:28:07 +01:00
Bram KragtenandClaude Opus 5.5 9925c10d03 Escape data URL links in markdown (#55558)
The data URL exception for links wrote the href back unescaped, so a quote
in the value could add attributes. It also added `download` to the cached
whitelist, which then allowed it in all later markdown.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 15:04:47 +02:00
Aidan Timson 7fc68028ea Use generic picker for install another version (#55552) 2026-11-02 13:46:54 +01:00
Paul Bottein 6136ec4909 Show the area first in integration device rows (#55557) 2026-11-02 13:23:06 +01:00
Josef Zweck d8a366b3d4 Add access risk to the Marketplace warning (#55559) 2026-11-02 13:08:54 +01:00
Paulus SchoutsenandClaude Opus 5.5 03e5814a57 Link the integration page to the integration's settings panel (#55514)
* Link the integration page to the integration's settings panel

The Connectivity settings panels, like Modbus and Infrared, are not
reachable from the integration page unless the integration has a config
entry. A user that opens the integration page looks for these settings.

The integration page now shows a button that opens the panel of the
integration when the integration has no entries. With entries, each
entry row already links to the panel. The button uses the panel name
from the Connectivity page. LoRaWAN, Modbus, Radio frequency, and USB
(Serial) are added to the map of integrations with a panel.

An integration with a panel and no config flow is set up in its panel.
For such an integration, the button is the primary action, and the page
does not show the YAML texts, the empty entries section, or the
"Add entry" button.

The demo Infrared and Radio frequency manifests now match core, so
these pages show it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EzDS5Gc7EVw1NsLpEB7yza

* Always show the settings panel button

An entry row links to the panel only while the entry is loaded. When
all entries are disabled, not loaded, or still setting up, the page had
no link to the panel. The integration page now always shows the
settings panel button.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EzDS5Gc7EVw1NsLpEB7yza

---------

Co-authored-by: Claude <[email protected]>
2026-11-02 11:40:28 +01:00
Vu Linh TruongandClaude Opus 5.5 9421cda8cf Use the dark brand icon in state badges when dark mode is on (#55493)
* Use the dark brand icon in state badges when dark mode is on

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

* Only follow dark mode from the UI context in state badges

Subscribing to the whole UI context made every state badge run an update
whenever an unrelated value such as the panel URL or the docked sidebar
changed. Consume only `themes.darkMode` through `@transform`, like
ha-media-browser-thumbnail does, so badges update only when dark mode
itself changes.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 11:40:23 +01:00
Vu Linh TruongandClaude Opus 5.5 25490642f9 Wrap media player row controls instead of overflowing narrow cards (#55492)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 11:40:17 +01:00
Norbert Rittel b01892a128 Entity ID format: Clarify usage for both new and restored IDs (#55495)
* Change "new entity" to "new ID"

* Fix grammar for "its settings"
2026-11-02 11:40:12 +01:00
JonathanandXyolyp 7baeed0cf6 Fix the lit-analyzer error for the ha-button mock in the flow dialog test (#55432)
Co-authored-by: Xyolyp <[email protected]>
2026-11-02 11:40:08 +01:00
renovate[bot] f1a9e2f69a Update dependency @versatiles/style to v7.1.0 (#55554)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 11:40:04 +01:00
Aidan Timson fdfc00f3d7 Add loading state guidance to the components skill (#55455)
* Add loading state guidance to the components skill

* Describe spinner delays as context-specific
2026-11-02 10:35:46 +00:00
WendelinandClaude Opus 5.5 ee33e4bfde Fix picker rows missing the first click after scrolling (#55556)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-02 11:32:34 +01:00
Aidan Timson 39754807cc Show skeletons while the system log and older log lines load (#55456) 2026-11-02 11:31:34 +01:00
Jump-333 752251b1b6 Severity and segments to the tile card bar gauge (#55507) 2026-11-02 11:25:21 +01:00
Sridhar NetiandClaude Opus 5.5 95c613b098 Hide light brightness handle when brightness is unknown (#55277)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 10:13:01 +00:00
Paul Bottein a970c5c7f6 Limit the more info activity to the 100 most recent entries (#55553) 2026-11-02 11:10:45 +01:00
Simon Lamon 78f6a55e3c ha-scene-editor migrate list (#55478) 2026-11-02 09:58:20 +00:00
Simon Lamon 563b90c773 dialog-data-table-settings migrate list (#55464)
* dialog-data-table-settings migrate list

* Spacing
2026-11-02 09:58:16 +00:00
Paulus SchoutsenandClaude bf996a2521 Link the MCP integration pages to the AI settings (#55515)
The AI settings show how to connect to the MCP server and the URL of
each of its APIs. The MCP client and MCP server integration pages now
have an "AI settings" button that opens the MCP card on that page. The
button is the primary action when the page has no "Add entry" button.

The AI settings page scrolls to the section named in the URL hash, like
the backup settings page, and clears the hash after. In the demo, the
hash is the route of the page, so the button links to the page without
a section and the page keeps a hash that names no section.


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

Co-authored-by: Claude <[email protected]>
2026-11-02 10:18:26 +01:00
Aidan Timson f04ce740a1 Shorten the Marketplace description on the settings page (#55549) 2026-11-02 10:05:18 +01:00
Petar Petrov 4a455cd883 Use the detected time zone in onboarding (#55545) 2026-11-02 09:19:44 +01:00
jimjaeger 6ae0d2ac72 Fix onboarding using US customary units when the location is detected (#55535)
Keep the detected unit system in onboarding
2026-11-02 09:13:22 +02:00
Joost LekkerkerkerandClaude 4b156e5666 Stretch chip container to fill host so outline matches host shape (#55510)
Co-authored-by: Claude <[email protected]>
2026-11-02 08:12:36 +01:00
Erwin DounaandAidan Timson ede6d92ab6 Show a skeleton while the live log loads (#55443)
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 08:09:59 +01:00
Thomas Mueller 5a4e664597 Seek on slider change in the media control card (#55542)
* Seek on slider change in the media control card

A touch drag ends without a click, so the card never seeked after a
drag. Use the slider's change event, which fires when a drag or tap
ends and on arrow keys.

* Remove media control card rendering test

The frontend testing guidance asks for tests of code that computes
something, not rendering tests.
2026-11-02 06:05:25 +00:00
renovate[bot] 40d2739a5f Update actions/setup-python action to v7.1.0 (#55544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 07:05:22 +01:00
renovate[bot] 52f632e4c2 Update pypa/gh-action-pypi-publish action to v1.14.3 (#55543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 07:05:04 +01:00
renovate[bot] 5ac2316f79 Update babel monorepo to v8.0.10 (#55531)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 07:03:47 +01:00
Thomas Mueller 22ff94a5d6 Fix media progress slider on Chromium < 125 (#55539)
matches(":state(dragging)") throws before Chromium 125. Use
customStates.has("dragging") instead.
2026-11-02 06:02:28 +00:00
karwosts 7d2fa3cf5c Fix date-only attribute formatting to not shift into other days (#55536) 2026-11-02 07:00:05 +01:00
renovate[bot] b8f0213d16 Update github/codeql-action action to v4.39.1 (#55541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 06:54:22 +01:00
renovate[bot] d4e0da9e21 Update home-assistant/actions digest to 86d4564 (#55540)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 06:54:06 +01:00
renovate[bot] 95c75bdd5b Update dependency @rspack/core to v2.3.0 (#55524)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-01 16:07:57 +01:00
renovate[bot] 36d57f1bba Update dependency hls.js to v1.7.4 (#55517)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-01 08:19:51 +02:00
renovate[bot] 95d4e7705f Update dependency @vvo/tzdb to v6.202.0 (#55518)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-01 08:19:43 +02:00
Paulus SchoutsenandClaude Opus 5.5 87babb1a3b Match installed apps card spacing to the integrations dashboard (#55497)
* Align installed apps cards with the search field

The installed apps grid got an 8px margin from the shared apps style
on top of its own 16px padding. The cards sat 24px from the screen
edge while the search field sits at 16px. Reset the margin so the
cards line up with the search field and are wider on mobile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016677RTobVvbXRTMLYj3N4m

* Limit apps card margin change to mobile screens

Remove the side margin of the installed apps grid only below 600px.
Wider screens keep the 24px edge spacing, which matches the
integrations page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016677RTobVvbXRTMLYj3N4m

* Match installed apps grid spacing to the integrations dashboard

Use the same container spacing as the integrations dashboard: 16px
from the side edges and 24px below the toolbar on all screen sizes.
The margin from the shared apps style is reset, so it no longer adds
to the padding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016677RTobVvbXRTMLYj3N4m

---------

Co-authored-by: Claude <[email protected]>
2026-10-31 09:51:11 +01:00
Ruslan Sayfutdinov 2966588338 Fit the backup progress labels on narrow screens (#55502)
* Fit the backup progress labels on narrow screens

* Render the backup stage label only when there is a stage
2026-10-31 09:48:58 +01:00
Jan-Philipp BeneckeandCopilot Autofix powered by AI 7e07d3b5c4 Fix network graph button spacing (#55501)
* Fix zoom reset button spacing

* Replace margin-right with margin-inline-end

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

* Align with chart-base

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-31 09:47:52 +01:00
Ruslan Sayfutdinov 1b9d866977 Wrap long lines in the backup summary card (#55500) 2026-10-31 09:47:15 +01:00
Ruslan Sayfutdinov 0fc4a042c5 Restore bottom padding of the backup summary card (#55498) 2026-10-31 09:46:52 +01:00
renovate[bot] b865ca9357 Update dependency glob to v14.0.3 (#55505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-31 09:21:34 +01:00
renovate[bot] f817d76b73 Update dependency glob to v14 (#55503)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-31 08:24:21 +02:00
renovate[bot] c695f577ff Update pnpm to v12.16.1 (#55496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 20:14:52 +02:00
Chanelle 4cb82bf40b Announce the number of search results in pickers (#55324)
* Announce the number of search results in pickers

Screen readers stay on the search field while the list filters, so typing in a picker or the quick search said nothing. A hidden status message now gives the number of matching items, or the not found label when nothing matches.

* Test the picker search results announcement

The status message is never seen, so a regression would go unnoticed. The test checks the count, the not found label, the cleared search, and that section titles are not counted.

* Count only matches and name the search in the picker announcement

The custom value row and the additional items, like the add new area row, were counted as results, so a search that matched nothing said there were results. They are no longer counted. A live region is only read out when its text changes, so a new search with the same count, or a second search that found nothing, said nothing. The message now names the search.

* Add the picker announcement messages to the gallery demo

The picker gallery's localizer only knew the older keys and returned the key for anything else, so searching a demo announced "ui.components.combo-box.result_count" instead of the count and the search. It now has both new messages, copied from en.json, and formats them with their values, so the demos announce "1 result for ‘desk’", "2 results for ‘kitchen’" and "No results for ‘zzz’".

* Move the search results message into a helper and test it there

The frontend testing guidance asks for no rendering tests, and the picker test mounted the component and checked its status text. The counting and the message now live in searchResultsStatus, a plain function the picker calls, with a unit test in its place: rows that are not results (section titles, placeholders, rows like "Add custom item") are not counted, a not found label that takes the search is used, and the message names the search so a new search is read out. The announcements are unchanged.

* Wait for typing to pause before announcing, and don't count Ask Assist

The picker announcement changed on every key press, so a screen reader started a new announcement for each character, over its own echo of the typed characters. The message now updates 500 ms after the rows stop changing, using the existing debounce helper, and is no longer recounted on every render.

The quick bar adds an "Ask Assist" row to every search through getItems, so it was counted: searching for something that matched nothing announced "1 result". The picker takes a new isSearchResult callback for rows like this, and the quick bar uses it to leave the assist row out. It isn't skipped in _isOption, because that would also stop the row being selectable. The gallery page documents both.

* Clear the picker announcement as soon as the rows change

With the pause, the last message stayed in the status region for half a second after the rows had changed, so it could describe the previous search. Going back to a search that had already been announced also wrote the same text again, so nothing was read out. The message is now cleared as soon as the rows change, at once when the search is emptied, and when the picker is removed. Clearing a status region isn't read out, and the next message is a change, so it is.
2026-10-30 12:58:47 +02:00
Daan Vervacke 449e7f8501 Wrap long title in YAML-only integration dialog (#55481) 2026-10-30 09:22:40 +01:00
Paul Bottein 7b0d350fb7 Add translation for the Marketplace panel title (#55486) 2026-10-30 08:19:14 +01:00
Maarten Lakerveld ed2cc613e1 Move the panel background stacking context off partial-panel-resolver (#55485)
Since #54801 the resolver creates a stacking context (isolation: isolate) so
its fixed ::before background sits behind the panel. The resolver is rendered
conditionally, once the startup data is available, and isolating it can
intermittently leave the Android WebView accessibility tree (Chromium
109-124) with an empty panel subtree while the page paints normally. UI
automation then never sees the dashboard; the companion app nightly E2E has
hit this on the legacy build (API 32/33/35) since the change landed.

Move the isolation to home-assistant-main's host, which exists before the
resolver is rendered. The pseudo-element and the Lovelace view background
both stay at z-index -1 in that context, in the same order as before, so the
themed background behaves exactly as #54801 intended. Measured with Maestro
on a Chrome 109 emulator: 0 stale trees in 62 cold loads, against 11 in 100
with the isolation on the resolver.
2026-10-30 08:18:36 +02:00
renovate[bot] 79f193f0d8 Update pnpm to v12.16.0 (#55490)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 08:14:44 +02:00
renovate[bot] 149f24255b Update dependency typescript-eslint to v8.72.1 (#55483)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 08:14:37 +02:00
renovate[bot] c1a82bfe7f Update dependency browserslist to v4.29.5 (#55482)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 08:14:29 +02:00
renovate[bot] 5083578c1e Update Node.js to v24.22.2 (#55489)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 08:14:21 +02:00
Paul Bottein 5f11dc4580 Replace hide-zoom-controls with a zoom slider mode on charts (#55468) 2026-10-29 17:12:40 +01:00
Simon Lamon 27489f188e date-range-picker migrate list (#55471) 2026-10-29 17:57:24 +02:00
Simon Lamon 97fffc3b43 ha-config-person migrate list (#55473) 2026-10-29 17:56:23 +02:00
renovate[bot] a30193b8ef Update dependency marked to v18.1.2 (#55470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 17:50:42 +02:00
Aidan Timson cf063dc19f Update the PR template for the contributors docs (#55459) 2026-10-29 17:05:36 +02:00
Simon Lamon 5db829b7be pick-auth-provider migrate list (#55462) 2026-10-29 17:04:51 +02:00
Maarten Lakerveld 421f8b1986 Load the legacy Intl polyfills once per page and bundle PluralRules with its data (#55452)
Each legacy entry bundle carries its own copy of intl-polyfill.ts, so the
module-level guard only stopped re-runs within one bundle. With the bundles
loading in parallel, the second bundle could probe Intl.PluralRules after the
first had installed the polyfill but before its locale data chunk arrived. The
FormatJS should-polyfill probe calls select(), which throws on a data-less
polyfill, rejecting the top-level await so the entry never evaluated. On the
companion app this surfaces as ExternalBusTimeout on old WebViews.

Share one promise per page on globalThis so every bundle waits for the same
run, and import the PluralRules polyfill and its English data from one module
so Intl.PluralRules is never installed without data.
2026-10-29 17:02:23 +02:00
Simon Lamon a140eedc6e Clean up ha-radio-list-item (#55454) 2026-10-29 17:00:59 +02:00
renovate[bot] 2933568658 Update dependency home-assistant-js-websocket to v9.8.1 (#55450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 16:59:26 +02:00
Simon Lamon 29faeaf7fa Remove incorrect type usage from date-range-picker (#55460) 2026-10-29 16:58:55 +02:00
Simon Lamon 95ba532ceb Removes incorrect type from cast (#55461) 2026-10-29 16:58:33 +02:00
Guillaume Rischard 352ecb9d6d Simplify OpenStreetMap attribution (#55447)
As per https://osmfoundation.org/wiki/Licence/Attribution_Guidelines:

> Attribution must be to “OpenStreetMap”.
[...]
> The historical forms of attribution “© OpenStreetMap contributors” or “© OpenStreetMap” are acceptable.
2026-10-29 15:27:49 +01:00
Erwin Douna 3f73049a3d Fade out energy period loading spinner (#55431) 2026-10-29 15:06:53 +01:00
Petar Petrov 50ad0c6cd0 Name new disk mounts after the partition's volume label (#55441) 2026-10-29 14:58:56 +01:00
Paul Bottein 78f95bbb06 Show a toast with a link to the dashboard after adding to it (#55444) 2026-10-29 14:36:46 +01:00
WendelinandClaude Opus 5.5 6134056776 Migrate the device, area and integration lists to ha-list-base (#55445)
* Migrate the config device, area and integration lists from ha-list

Moves the device child, via and linked devices cards, the device
entities card, the area page, the Matter fabrics and ping dialogs, the
Thread border routers, the Z-Wave remove node dialog and the Z-Wave
config entry picker from the mwc-based ha-list and ha-list-item to
ha-list-base, ha-list-nav, ha-list-item-button and ha-list-item-base.

- Render links as ha-list-item-button with an href instead of an anchor
  around a list item, so the rows take part in keyboard navigation
- Render automations and scenes that are not editable as
  ha-list-item-base with the existing tooltip, as on the device page
- Wrap the area scripts and the Thread border routers in a list, as
  the other sections already were
- Only render the remove fabric button where mwc showed it, and give it
  a label
- Keep the list padding and row alignment of the previous lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Add aria labels and adjust padding for ha-list components in device and integration dialogs

* Refactor aria labels for related lists in ha-config-area-page and update translations

---------

Co-authored-by: Claude <[email protected]>
2026-10-29 14:09:58 +01:00
Aidan Timson b79c6350ec Add date format presets to the clock and date cards (#55434) 2026-10-29 11:15:42 +00:00
Paul Bottein 8148041ddc Show multi-select chips in a field with an inline add button (#55416) 2026-10-29 09:59:54 +00:00
WendelinandClaude Opus 5.5 4265624c0e Wrap long text in debug tool and network discovery rows (#55438)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-29 09:40:29 +00:00
Paul Bottein ed4179de8f Use the state color by default in alert cards and badges (#55436) 2026-10-29 09:29:06 +00:00
WendelinandClaude Opus 5.5 e407a1b2dd Migrate the remaining selection lists to ha-list-selectable (#55437)
* Migrate the remaining selection lists from ha-list to ha-list-selectable

Moves the media management dialog, the calendar panel pane, the expose
entities dialog, the user visibility condition and the select view dialog
from the mwc-based ha-list, ha-check-list-item and ha-radio-list-item to
ha-list-selectable and ha-list-item-option. The read-only ZHA endpoint
list now uses ha-list-base, as its rows already were ha-list-item-base.

- Use ha-list-selectable-virtualized for the expose entities dialog,
  replacing the custom click and keyboard handling around lit-virtualizer
- Render the calendar pane as its own list instead of reusing the
  dropdown items, sharing the selection logic with the dropdown
- Make the select view dialog a single-select list and keep the icon
  column aligned for views without an icon
- Drop the media management workaround for spurious mwc select events
- Render the create calendar row as ha-list-item-button
- Remove unused element imports in the touched files

After this, ha-check-list-item is only used by the todo list card and
ha-radio-list-item is unused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Refactor ha-list components for improved focusability handling and update list items logic

* Enhance focus handling in ha-list-base and update tests for disabled items

* use helper

---------

Co-authored-by: Claude <[email protected]>
2026-10-29 11:16:09 +02:00
Aidan Timson e851101a93 Fix the Marketplace search field overlapping the top bar icons on narrow screens (#55439)
Let the narrow data table search field shrink to fit
2026-10-29 11:13:19 +02:00
Tristan Cartledge d43e1ef4cc Fix duplicated hidden areas when reordering areas in the areas dashboard editor (#55424) 2026-10-29 09:37:33 +01:00
Petar Petrov f699019994 Ignore YAML dashboard update event on first load (#55430) 2026-10-29 09:28:05 +01:00
Daan Vervacke fe90b0339d Show blueprint usage count as text on narrow screens (#55427)
* Show blueprint usage count as text on narrow screens

* Show the blueprint usage count before the path on narrow screens

* Name the blueprint usage column and skip it for broken blueprints
2026-10-29 08:34:42 +02:00
renovate[bot] 3b5ba802f6 Update dependency @rspack/dev-server to v2.2.2 (#55429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 08:30:43 +02:00
JonathanandXyolyp b0c6e7f564 Preselect the only allowed unit also in an optional unit selector (#55417)
Co-authored-by: Xyolyp <[email protected]>
2026-10-29 08:17:31 +02:00
renovate[bot] 658a23724e Update tsparticles to v4.5.0 (#55428)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 08:00:52 +02:00
Maarten Lakerveld f859183628 Name maintenance problem tiles after the device and the entity (#55420)
Problem binary sensors were added to the maintenance view using the
battery tile builder, which names every tile after its device. A device
with several problem sensors (overheating, overvoltage, ...) then showed
identical tiles. Problem tiles now use a device plus entity name; the
entity part is dropped automatically when the sensor has no name of its
own, so single-sensor devices still read as just the device name.
Battery tiles keep the device-only name.

Fixes home-assistant/frontend#55413
2026-10-28 20:42:19 +01:00
Paul Bottein 53dc14f413 Keep the section row divider under the entities card header (#55415) 2026-10-28 19:42:41 +01:00
Aidan Timson f2523095ff Remember logs page search, integrations and raw view (#55399)
* Remember logs page search, integrations and raw view

* Keep stored logs choices from links and loading apps

* Keep logs choices when closing dialogs and while apps load

* Restore stored logs choices when linked to the page again
2026-10-28 16:03:00 +00:00
ef5c04b67f Show stop button in the media player more-info dialog (#54794)
* Don't hide stop action on playing media players

* Show stop as a secondary button when play/pause exists

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

* Add media player entity for "Playing The Music" and update media control card

* Implement logic to remove secondary stop control when play or pause is available in media player

* Test removing the secondary stop control

Runs real computeMediaControls output through removeSecondaryStopControl,
like its callers do, to protect when stop is removed and when it has to
stay.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-10-28 18:01:26 +02:00
WendelinandClaude Opus 5.5 10ed6f709e Migrate the data table filters to ha-list-selectable (#55412)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-28 16:55:17 +01:00
Bram KragtenandClaude Opus 5.5 c22de3bc7c Keep the device and area page headers fixed while scrolling (#55409)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-28 16:08:12 +01:00
Bram Kragten 37f67a2f45 Bumped version to 20261028.0 2026-10-28 16:04:03 +01:00
Petar Petrov 06df9e3801 Tolerate missing rows and invalid visibility entries (#55408) 2026-10-28 14:58:29 +00:00
renovate[bot] 7f45b38d6f Update dependency home-assistant-js-websocket to v9.8.0 (#55389)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-28 14:42:54 +00:00
renovate[bot] 5c0f9b6501 Update dependency maplibre-gl to v6.16.0 (#55406) 2026-10-28 14:40:21 +00:00
Wendelin 645811dfdf Show entity pictures in the add automation element target tree (#55407) 2026-10-28 15:28:25 +01:00
Paul Bottein 45ec2e805a Rename the MCP card URL labels (#55401) 2026-10-28 15:10:56 +01:00
Paul BotteinandBram Kragten 18c93baa70 Add device to dashboard from the device page (#55381)
Co-authored-by: Bram Kragten <[email protected]>
2026-10-28 14:06:15 +00:00
Petar Petrov 2518c6ea77 Keep fitted neighbors clear of the network graph controls (#55405) 2026-10-28 14:48:56 +01:00
Bram Kragten 702381515a Merge branch 'rc' into dev 2026-10-28 14:43:44 +01:00
Przemysław Szypowicz 93efd84fab Load custom modules after the scoped registry polyfill (#54444) 2026-10-28 14:29:23 +01:00
Pradeep Peddineniandpradeeppeddineni be3841ff0d Fit gauge card to the card height in sections view (#55260)
* Fit gauge card to the card height in sections view

* Address review: flex container for gauge, min_rows 2, drop DOM tests

- Wrap ha-gauge in a container that takes the space left after the name
  (flex: 1, min-height: 0, container-type: size) and size the gauge with
  min(250px, 100cqw, 100cqh * 100 / 55) instead of the calc
- Use typeof rows === "number" like the other cards
- Set min_rows: 2 in getGridOptions, one row leaves almost no room
- Remove the DOM tests

---------

Co-authored-by: pradeeppeddineni <[email protected]>
2026-10-28 14:23:28 +01:00
Paul Bottein bfd6ff56a5 Show the helper under the label in checkbox lists (#55400) 2026-10-28 13:21:23 +00:00
Petar Petrov 41ce82ca01 Keep network graph labels when zooming during their fade in (#55397)
With physics off, graph labels fade in after the physics toggle or a
chart rebuild. A roam updates label layout instantly, and ECharts then
stops every label animation, which froze the fade at its current
opacity. Finish the fade before such an instant update.
2026-10-28 14:14:15 +01:00
Petar Petrov f8826fc821 Highlight the device in the ZHA network visualization (#55394) 2026-10-28 14:11:25 +01:00
Petar Petrov c0a98dba54 Keep entity selector data while it refreshes after a reconnect (#55396) 2026-10-28 13:00:50 +00:00
Erwin Douna 839a910a19 Start logs page filters collapsed (#55398) 2026-10-28 12:54:26 +00:00
Petar Petrov 345745ef47 Call the energy power sensor derived, not a helper (#55395) 2026-10-28 12:29:55 +00:00
João Pedro HickmannandPetar Petrov c4c0ae0dde Animate energy distribution flow dots on the compositor (#54872)
Co-authored-by: Petar Petrov <[email protected]>
2026-10-28 13:05:56 +01:00
6297d3e972 Add Zigbee Green Power support to ZHA (#55322)
* Add ZHADeviceType enum for ZHA device types

* Translate ZHA device types

* Support Green Power devices in ZHA panels

* Reword joining text to reflect device-driven joining, ZHA doesn't search

* Use translations for `Source ID`

* Update src/panels/config/integrations/integration-panels/zha/functions.ts

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

---------

Co-authored-by: Simon Lamon <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-10-28 14:04:22 +02:00
Petar Petrov 8985b7d08d Offer gas and water sensors from flow rates (#55360)
* Offer to create an energy sensor for power-only energy devices

An individual device needs an energy sensor, so a device with only a
power sensor could not be saved. Saving now asks to create an Integral
helper from the power sensor, or to reuse one that already integrates
it, and uses it as the device's energy sensor.

* Offer energy sensors from power for solar and home sources

Move the Integral helper flow out of the device dialog into a shared
confirmIntegrals helper. The confirmation now creates the helpers itself
and shows its progress. Solar production and the home meter can be saved
with only a power sensor, like individual devices.

* Offer gas and water sensors from flow rates

Gas and water sources and individual water devices can now be saved with
only a flow rate sensor. Saving offers to create an Integral helper with the
gas or water device class, or to reuse one that already integrates the rate.

* Offer energy sensors from power for grid and battery sources

A grid connection or battery with two power sensors can now create Integral
helpers for its missing energy sensors on save, or reuse existing ones. A
battery needs both, while a grid connection can skip them and save as before.

* Keep a device's children out of its upstream choices without an energy sensor

Clearing the energy sensor while editing a device dropped its children from the
check, so a child could be picked as the upstream device. Saving then reuses
the original Integral helper, which made the hierarchy circular.

* Save the submitted device when its dialog closes during helper creation

Snapshot the draft before creating the Integral helper, and only update the
dialog while the same dialog session is open. Always pass the energy device
class, as the helper only infers it from a power device class.

* Save the submitted water device when its dialog closes during helper creation

Mirror the electricity device dialog: snapshot the draft before creating the
Integral helper, and only update the dialog while the same session is open.

* Price gas in the unit of a reused helper

A reused Integral helper can display another unit than a new one would, so
take the price unit from that helper while only a flow rate is set.

* Keep grid and battery helpers apart and wait for all of them

Neither direction of a source may reuse the statistic picked for the other.
When one of several helpers fails, wait for the others to finish, so a retry
reuses them instead of creating duplicates.

* Lock the device form while it saves

The helper is created after the confirmation closes, so edits made meanwhile
would be replaced by the submitted device.

* Lock the water device form while it saves

Mirror the electricity device dialog, so edits can't be replaced by the
submitted device while its helper is created.

* Save the submitted solar and home sources

Like the device dialogs: snapshot the source before asking for the helper,
lock the inputs while saving, and only touch the dialog while the same
session is open.

* Save the submitted grid and battery sources

Like the other energy dialogs: snapshot the source and power settings before
asking for helpers, lock the energy inputs while saving, and only touch the
dialog while the same session is open.

* Save the submitted gas and water sources

Like the other energy dialogs: snapshot the source and cost choice before
asking for the helper, lock the inputs while saving, and only touch the dialog
while the same session is open.

* Keep a device's name and upstream device when its energy sensor changes

A device can now be named and given an upstream device before it has an energy
sensor, so picking or clearing the sensor must not drop them. The upstream
device is only dropped when the new sensor rules it out.

* Keep a water device's name and upstream device when its sensor changes

Mirror the electricity device dialog: picking or clearing the water sensor
keeps the name, and only drops the upstream device when the sensor rules it out.

* Lock the power settings while a grid or battery saves

Changing a power sensor during the save could pair the new sensor with helpers
for the old one after a failed save. Only the new energy sensors are written
back to the form, so other edits are kept.

* Keep solar and home edits when saving fails

Only write the new energy sensor back to the form, so changes made while the
source was saving survive a failed save.

* Keep gas and water edits when saving fails

Only write the new sensor back to the form, so changes made while the source
was saving survive a failed save.

* Lock the grid pricing controls while saving

Pricing edits made while the grid saved were lost, as the save uses the
submitted source.

* Leave gas and water prices in the form when saving fails

The no-cost reset only applies to the saved source, so a failed save no longer
clears the price text while keeping the hidden price.

* Reset the device dialog's saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Reset the solar and home saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Ask grid connections to create or cancel, without skipping

The grid confirmation now offers Cancel and Create like the other sources, so
it always has a way back to the editor. Grid and battery also reset their saving
state per session.

* Reset the gas and water saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Lock the gas and water pricing controls while saving

Pricing edits made while the source saved were lost, as the save uses the
submitted source.

* Lock the solar forecast controls while saving

Forecast changes made while the source saved were lost, as the save uses the
submitted source.

* Lock the battery state of charge and capacity while saving

Like the other inputs, so edits made while the battery saves aren't lost.

* Drop grid export compensation without an export meter

Compensation set while an export was pending could stay hidden in the source
after the power sensors changed, and come back once an export meter is added.

* Explain why a flow rate can't provide the gas or water sensor

When the flow rate can't become a meter, its picker now says so instead of
leaving Save disabled without a reason. The flow rate help text says
"Optionally" again, since it stays optional next to a meter.

* Find existing helpers in the live states

The home dialog passed the hass from when it opened, so a retry after a
failure didn't see a helper created by the first try and made a second one.
The helper now takes the live states and entities from each dialog. The
counting note also fits solar production now.

* Pass the live states to the grid and battery helpers

* Update Save when the power sensor's unit changes
2026-10-28 13:57:36 +02:00
WendelinandClaude Opus 5.5 9f6de3166f Show entity pictures in the target picker (#55387)
Show entity pictures in the target picker rows

Target picker rows for entities with an entity picture now show it, e.g.
the photo of a person, like the automation targets do since the entity
pictures fix.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-28 11:57:30 +00:00
WendelinandClaude Opus 5.5 64c6f2a133 Remove unused ha-list imports and migrate gallery off ha-list (#55393)
* Remove unused ha-list imports and migrate gallery off ha-list

Prepares the removal of the mwc-based ha-list components:

- Drop unused ha-list / ha-list-item element imports
- Drop leftover @material/mwc-list imports, typing the click handlers
  in ha-domain-integrations as MouseEvent
- Replace the ha-list wrapper in the date and time gallery pages with
  a plain div, as it only wrapped layout divs
- Update the ha-control-select-menu gallery page to pass options via
  the options property, as the component no longer renders slotted
  ha-list-item children, and keep the selected value in state

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Add HASSDomCurrentTargetEvent type to select menu and integration handlers

---------

Co-authored-by: Claude <[email protected]>
2026-10-28 11:52:22 +00:00
Petar Petrov d1c1d9a05d Offer energy sensors from power for grid and battery sources (#55359)
* Offer energy sensors from power for grid and battery sources

A grid connection or battery with two power sensors can now create Integral
helpers for its missing energy sensors on save, or reuse existing ones. A
battery needs both, while a grid connection can skip them and save as before.

* Keep grid and battery helpers apart and wait for all of them

Neither direction of a source may reuse the statistic picked for the other.
When one of several helpers fails, wait for the others to finish, so a retry
reuses them instead of creating duplicates.

* Save the submitted grid and battery sources

Like the other energy dialogs: snapshot the source and power settings before
asking for helpers, lock the energy inputs while saving, and only touch the
dialog while the same session is open.

* Lock the power settings while a grid or battery saves

Changing a power sensor during the save could pair the new sensor with helpers
for the old one after a failed save. Only the new energy sensors are written
back to the form, so other edits are kept.

* Lock the grid pricing controls while saving

Pricing edits made while the grid saved were lost, as the save uses the
submitted source.

* Ask grid connections to create or cancel, without skipping

The grid confirmation now offers Cancel and Create like the other sources, so
it always has a way back to the editor. Grid and battery also reset their saving
state per session.

* Lock the battery state of charge and capacity while saving

Like the other inputs, so edits made while the battery saves aren't lost.

* Drop grid export compensation without an export meter

Compensation set while an export was pending could stay hidden in the source
after the power sensors changed, and come back once an export meter is added.
2026-10-28 12:44:05 +01:00
Petar Petrov ef531bcb84 Offer energy sensors from power for solar and home sources (#55358)
* Offer to create an energy sensor for power-only energy devices

An individual device needs an energy sensor, so a device with only a
power sensor could not be saved. Saving now asks to create an Integral
helper from the power sensor, or to reuse one that already integrates
it, and uses it as the device's energy sensor.

* Offer energy sensors from power for solar and home sources

Move the Integral helper flow out of the device dialog into a shared
confirmIntegrals helper. The confirmation now creates the helpers itself
and shows its progress. Solar production and the home meter can be saved
with only a power sensor, like individual devices.

* Keep a device's children out of its upstream choices without an energy sensor

Clearing the energy sensor while editing a device dropped its children from the
check, so a child could be picked as the upstream device. Saving then reuses
the original Integral helper, which made the hierarchy circular.

* Save the submitted device when its dialog closes during helper creation

Snapshot the draft before creating the Integral helper, and only update the
dialog while the same dialog session is open. Always pass the energy device
class, as the helper only infers it from a power device class.

* Lock the device form while it saves

The helper is created after the confirmation closes, so edits made meanwhile
would be replaced by the submitted device.

* Save the submitted solar and home sources

Like the device dialogs: snapshot the source before asking for the helper,
lock the inputs while saving, and only touch the dialog while the same
session is open.

* Keep a device's name and upstream device when its energy sensor changes

A device can now be named and given an upstream device before it has an energy
sensor, so picking or clearing the sensor must not drop them. The upstream
device is only dropped when the new sensor rules it out.

* Keep solar and home edits when saving fails

Only write the new energy sensor back to the form, so changes made while the
source was saving survive a failed save.

* Reset the device dialog's saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Reset the solar and home saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Lock the solar forecast controls while saving

Forecast changes made while the source saved were lost, as the save uses the
submitted source.

* Find existing helpers in the live states

The home dialog passed the hass from when it opened, so a retry after a
failure didn't see a helper created by the first try and made a second one.
The helper now takes the live states and entities from each dialog. The
counting note also fits solar production now.

* Update Save when the power sensor's unit changes
2026-10-28 12:44:04 +01:00
Petar Petrov 14252d09fb Drop hass from the device pickers (#55384)
* Drop hass from the device pickers

getDevices takes an options object and fetchDeviceCompositeSplits takes connection and callWS.

* Refresh the device picker value when the brands token arrives

Key the memoized value renderer on the brands access token so the selected device's integration icon gets its URL.

* Fetch config entries once in the device selector

Restores the one-time fetch with an empty-list fallback so a failed request no longer hides the selector.
2026-10-28 12:37:46 +01:00
Petar Petrov 12e32ce0ce Drop hass from the target picker item rows (#55383)
filterTargetEntries takes states and the registries it reads as separate values.
2026-10-28 12:36:54 +01:00
Petar Petrov 5c88dc99c2 Drop hass from the entity selector (#55380)
* Drop hass from the entity selector

fetchEntitySourcesWithCache takes callWS and states and caches per connection in module scope.

* Refetch entity selector data on reconnect

Fetch config entries once per connection again and keep overlapping cache callers from overwriting newer results.

* Refetch entity selector data when the connection comes back

Reset entity sources and config entries when the websocket reconnects, and drop an unreachable cache guard.
2026-10-28 12:36:15 +01:00
Petar Petrov 47c3b2f57c Drop hass from the media browser and its dialogs (#55385)
* Drop hass from the media browser and its dialogs

The media browse helpers now take callWS, and the browser, its manual entry form and the browse and manage dialogs read contexts instead.

* Refresh manual media entry labels when the language changes

Key the ha-form label and helper callbacks on localize so they update without a hass change.
2026-10-28 11:35:49 +00:00
Petar Petrov d1c2915c4e Drop hass from the category picker (#55378)
* Drop hass from the category picker

Consume the connection, API and internationalization contexts in ha-category-picker and drop SubscribeMixin.

* Keep categories when the scope returns to the subscribed one

Clear the list only when the new scope differs from the active subscription's scope.
2026-10-28 12:35:07 +01:00
Petar Petrov 7f1aedcb95 Speed up the log viewer tests with a smaller line limit (#55392)
The retention and search tests filled the viewer with 5000 lines,
which takes over a second per test in jsdom and pushed the search
tests past the 5 s timeout on loaded CI runners. Run them against a
50-line limit instead and drop the raised timeouts.
2026-10-28 12:33:25 +01:00
Petar Petrov db63231a5d Drop hass from the area controls picker (#55379)
* Drop hass from the area controls picker

getAreaControlEntities takes states and registries as separate values.

* Keep states out of the area controls editor memo key

Restores the editor's previous caching so state changes do not rescan the area's entities.
2026-10-28 12:32:50 +01:00
Petar Petrov 0aabfa89bc Drop hass from the history charts (#55386)
* Drop hass from the history charts

The history line and timeline charts and state-history-charts consume contexts, and generateStateHistoryChartLineData takes localize and states.

* Rebuild line chart options only on chart updates

Entity state changes are compared when the chart updates, as before, instead of triggering their own update.
2026-10-28 11:32:22 +00:00
Simon Lamon 35601830a8 Rename to child devices (#55391)
Child devices
2026-10-28 12:31:03 +01:00
Paul Bottein fb882dacae Add convert to manual section action (#55377)
* Add convert to manual section action

* Keep the section when its strategy fails to generate during conversion

* Drop strategy disabled state when converting a section

* Skip the conversion save when the section changed meanwhile

* Always drop disabled when converting a section

* Add Oxford comma to convert to manual text
2026-10-28 12:10:00 +01:00
Petar Petrov aaad3f0d3d Offer to create an energy sensor for power-only energy devices (#55353)
* Offer to create an energy sensor for power-only energy devices

An individual device needs an energy sensor, so a device with only a
power sensor could not be saved. Saving now asks to create an Integral
helper from the power sensor, or to reuse one that already integrates
it, and uses it as the device's energy sensor.

* Keep a device's children out of its upstream choices without an energy sensor

Clearing the energy sensor while editing a device dropped its children from the
check, so a child could be picked as the upstream device. Saving then reuses
the original Integral helper, which made the hierarchy circular.

* Save the submitted device when its dialog closes during helper creation

Snapshot the draft before creating the Integral helper, and only update the
dialog while the same dialog session is open. Always pass the energy device
class, as the helper only infers it from a power device class.

* Lock the device form while it saves

The helper is created after the confirmation closes, so edits made meanwhile
would be replaced by the submitted device.

* Keep a device's name and upstream device when its energy sensor changes

A device can now be named and given an upstream device before it has an energy
sensor, so picking or clearing the sensor must not drop them. The upstream
device is only dropped when the new sensor rules it out.

* Reset the device dialog's saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.
2026-10-28 11:31:26 +01:00
Maarten Lakerveld ce2a9577e5 Refresh the todo card at browser midnight and test the subscription controller (#55356) 2026-10-28 10:48:37 +01:00
Petar Petrov 5bafeb875c Remove unused dynamic-to-generic group move from the automation picker (#55376) 2026-10-28 10:35:15 +01:00
Maarten Lakerveld a24881a54f Fetch the index fresh on navigation and harden the service worker update flow (#54790) 2026-10-28 10:27:49 +01:00
Petar Petrov 4decd87fd6 Drop hass from the navigation picker (#55373)
* Drop hass from the navigation picker

computeNavigationPathInfo and subscribeNavigationPathInfo take an options object of separate values.

* Rerender the navigation picker when related items change

The related section is read in render, and hass updates no longer rerender the picker.
2026-10-28 09:08:52 +00:00
Petar Petrov 7fd2a112bf Drop hass from the TTS media browser (#55370)
fetchCloudStatus now takes callWS, and ha-browse-media-tts reads localize, config and the API from contexts.
2026-10-28 09:08:44 +00:00
Petar Petrov fc22a2af50 Drop hass from the floor selector (#55369)
Read registries and the config entries API from contexts instead of hass.
2026-10-28 09:08:37 +00:00
Petar Petrov bcae4feba9 Drop hass from the entity name picker (#55367)
The entity name picker reads states, registries, localize and formatEntityName from contexts; its selector drops the unused SubscribeMixin.
2026-10-28 09:08:29 +00:00
Petar Petrov d0c81d6d5f Drop hass from the display state cards (#55368)
* Drop hass from the display state cards

The remaining display state cards read formatters, localize and the API from contexts, and the lock and update helpers take the separate values they read.

* Call the entity registry directly in callProtectedLockService

Avoid a hass-shaped literal for getExtendedEntityRegistryEntry.
2026-10-28 09:08:21 +00:00
Raphael Hehl 711ff63983 Select and drag picture elements in the card editor preview (#55263)
* Select and drag picture elements in the card editor preview

* Recreate the element editor on preview selection and moves

* Select a preview element activated by a click alone
2026-10-28 10:07:02 +01:00
Petar Petrov 1bc800d042 Drop hass from the category group header menu (#55374)
Turn the category group menu into a context element

The menu stored hass on its dropdown for its handlers; it now consumes localize and the API context.
2026-10-28 09:00:38 +00:00
Niels 96605b8986 Move manual event trigger into the Event category under Generic (#55348) 2026-10-28 11:00:33 +02:00
Petar Petrov 7d374f9798 Drop hass from the action state cards (#55371) 2026-10-28 09:55:40 +01:00
karwosts 920cca10d1 Update behavior of continue_on_timeout toggle (#54964) 2026-10-28 09:54:39 +01:00
Lackmake cc3334dfce Add color setting to sensor card (#55364) 2026-10-28 09:52:25 +01:00
fcd17e3f2a Translate errors when describing triggers, conditions and actions (#55249)
* Translate errors when describing triggers, conditions and actions

* Delete test/data/describe-error.test.ts

---------

Co-authored-by: Pradeep Peddineni <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-10-28 10:52:02 +02:00
renovate[bot] da0935c904 Update dependency @home-assistant/webawesome to v3.14.0-ha.2 (#55375) 2026-10-28 08:51:28 +00:00
shazilkhanandJan-Philipp Benecke 38bf0a9ac3 Show custom trigger IDs in the "Triggered by" condition (#55086)
* Show custom trigger IDs in the "Triggered by" condition

Since 2026.10 the trigger list in the "Triggered by" condition only shows the
generated description, and a trigger's ID only appears when it is a duplicate.
Automations that relied on descriptive custom IDs can no longer tell similar
triggers apart.

Show the ID in the same end slot whenever it was set by the user, with a
neutral badge. Generated IDs stay hidden and duplicates keep the warning badge.

Fixes #54721

* Process UX feedback

* Fix accessibility issue

---------

Co-authored-by: Jan-Philipp Benecke <[email protected]>
2026-10-28 09:50:20 +01:00
Jan-Philipp Benecke 01bf2a402b Add bulk expose to voice assistants on the entities table (#55204)
* Add bulk expose to voice assistants on the entities table

* Add exposed entities parameter to voice assistant ID retrieval

* Add toast notifications for exposing and unexposing entities to voice assistants

* Fetch current exposure from core before determining changed entities for assistants

* Adapt to core change

* Remove dropdown fix

* Update toast message count to reflect core changed entities

* Update stub

* Return a fresh copy of stub
2026-10-28 08:48:43 +00:00
NielsandWendelin 4b20e52c29 Add Replace for automation triggers, conditions, and actions (#55131)
Co-authored-by: Wendelin <[email protected]>
2026-10-28 08:46:53 +00:00
Petar Petrov cdc08029e0 Drop hass from the statistic pickers (#55372) 2026-10-28 09:30:27 +01:00
Paul Bottein 5bbe135c53 Add formatNumberWithUnit helper (#55346)
* Add formatNumberWithUnit helper

* Extract formatted values into named variables
2026-10-28 06:05:45 +00:00
Paul Bottein 68445a8f94 Use a non-breaking space between values and units (#55345) 2026-10-28 08:00:17 +02:00
karwosts 316043960c Fix helper text spacing in location selector (#55349)
Fix helper text in location selector
2026-10-27 20:45:17 +01:00
Petar Petrov d8dde4a0bb Refit legends when the More button changes width (#55357)
Refit the legend when the More button changes width
2026-10-27 18:31:59 +01:00
Maarten Lakerveld 525f2c23d6 Drop hass from the shortcut card and the navigation path controller (#55334)
NavigationPathInfoController resolves localize, panels, areas, devices,
config and connection from context and only takes a path, so the
shortcut card, badge, editors and home shortcut dialogs no longer pass
hass to it. It skips the host update when the resolved label and icon
did not change. getShortcutCardDefaults and the panel title helpers take
localize instead of hass. The badge and the editors keep hass for their
actions and forms.
2026-10-27 18:29:45 +01:00
Aidan Timson 41d43fe780 Edit categories from their data table group headers (#55236)
* Edit categories from their data table group headers

* Use an edit and delete menu on category group headers

* Drop a deleted category from the filter and pass callWS

* Name the category in its group header menu label

* Key category group header menus by category ID

* Keep group header menus at normal font weight

* Use the rows' icon size for group header actions, keep marketplace at 20px
2026-10-27 18:26:40 +01:00
Petar Petrov 5b4a9a8967 Fit chart legends to two rows (#55351)
* Fit chart legends to two rows

Use the legend fit controller in ha-chart-base instead of hiding
everything after a fixed count, and resize the chart when the legend
changes height within a fixed height.

* Refit the chart legend when its items change
2026-10-27 18:19:15 +02:00
Maarten Lakerveld 3fe979408a Drop hass from the button, gauge, light, plant, picture and markdown cards (#55329)
* Drop hass from the button, gauge, light, plant, picture and markdown cards

Each card consumes the entity state and registry entry it shows plus the
formatters, localize, config and themes it reads. Actions are dispatched
as hass-action events. The picture card resolves media source images
once per image id, and the markdown card resubscribes its template when
the connection or user changes. The plant status card selects its sensor
states from the configured entity directly, so the selection does not
depend on transform ordering.

* Let the picture card retry a media source that failed to resolve

A rejected resolve left the in-progress marker set, so the same image
was never requested again.

* Subscribe the markdown template once per connection and user

The first context values arrive before connectedCallback() subscribes, so
the first update replaced a subscription that was already live. The card
now tracks the connection and user it subscribed with and only
resubscribes when one of them differs.
2026-10-27 17:13:18 +01:00
Petar Petrov 8b0d03cad2 Fit the distribution card legend to two rows (#55350)
* Fit the distribution card legend to two rows

Hide only the legend items that do not fit in two rows, instead of
everything after a fixed count. Each item is fitted at its widest since
the last resize, so changing values cannot make the More button flicker.

* Create the legend observers when the list renders

* Drop the legend mutation observer
2026-10-27 17:12:09 +01:00
Paul Bottein 76ee92807a Rename "Use device name" to "Main entity of the device" (#55320)
* Rename "Use device name" to "Main entity of the device"

* Explain main entities in the switch description

* Rename device name variables to main entity
2026-10-27 17:41:05 +02:00
Maarten Lakerveld 9f4fd39f3f Drop hass from the clock, glance and other simple cards (#54901)
* Drop hass from the clock, glance and other simple cards

The clock cards consume locale and the server time zone from context,
and the date format helper takes those values instead of hass. The
glance card consumes its entity states and registry entries through the
entity helpers, so it only rerenders when a shown entity changes. The
iframe, empty state, starting and toggle group cards consume localize,
config, states and api, and dispatch actions as hass-action events.

* Act from the element that fired hass-action and keep toggle group order

The shared handler ran handleAction on the app root, so fire-dom-event
dispatched ll-custom above the dashboard listeners and broke the Home
panel's Add device button. The toggle group now walks the configured
entity order, because the states record keeps its identity when only the
order changed.
2026-10-27 15:27:45 +00:00
Maarten Lakerveld 1fe09e6dbd Drop hass from the todo list and shopping list cards (#55331)
* Drop hass from the todo list and shopping list cards

The cards consume their entity state, states, entities, formatters,
localize, config, themes, connection and api from context. The todo
helpers take the connection, callService, callWS or states they use, so
the todo panel and item dialog pass those instead of hass.

* Refresh the todo card at midnight or at the next item deadline

Today labels and the overdue styling depend on the time. They used to
refresh with unrelated state updates, which no longer rerender the card.
The timer now fires at midnight or at the earliest upcoming deadline,
whichever is first, and is rearmed when the items change.
2026-10-27 15:27:41 +00:00
Maarten Lakerveld f74c63f257 Drop hass from the area controls card feature (#55333)
* Drop hass from the area controls card feature

The feature consumes the registries, localize and api it reads and
narrows statesContext to the entities its buttons show, so unrelated
state changes do not rerender it. The area control entities and group
toggle helpers take narrowed hass slices. The hui-card-feature wrappers
keep hass to feed custom features.

* Keep the default area controls stable and key the lookup on states

A fresh default array on every access missed both memoized lookups, which
now run per state notification as well as per render. States join the
cache key because the filter reads device classes from them, so an entity
can only match once its state exists.
2026-10-27 15:27:36 +00:00
Maarten Lakerveld 53a80e3056 Drop hass from the alarm panel, media control and weather forecast cards (#55330)
* Drop hass from the alarm panel, media control and weather forecast cards

The cards consume their entity state, formatters, localize, config,
themes, api and connection from context. callAlarmAction takes
callService and getSecondaryWeatherAttribute takes the formatter and
localize values it uses. The media control card extracts artwork colors
in update(), after @transform has refreshed the entity state for a new
config, and the alarm panel reports the keypad size before its first
update since masonry sizes cards before attaching them.

* Apply the media control theme without waiting for the entity

The theme was skipped when the player was missing on the config or
themes change, and nothing reapplied it once the entity appeared.
2026-10-27 15:27:31 +00:00
Maarten Lakerveld cc424b6a09 Drop hass from the map card and the trend graph card feature (#55332)
* Drop hass from the map card and the trend graph card feature

The map card narrows statesContext to the entities it tracks, including
the entities its visibility conditions read, so unrelated state changes
do not rerender it. The visibility condition checks take a narrowed hass
type that includes the registries the location condition resolves zones
through, the target selector helpers take what they read, and
extractConditionEntityIds visits every condition instead of returning at
the first logical group. The history stream helper takes a connection
and a states slice, which the map card and trend graph feature read live
so a resubscribe after a reconnect sees current states.

* Track core entity_id conditions and single-child groups on the map

The extractor skipped the core entity_id field that the checks prefer,
and threw on a logical group that holds one condition instead of a list.
It now also handles legacy conditions without a condition key.
2026-10-27 15:26:51 +00:00
Maarten Lakerveld c5818c5360 Drop hass from the repairs, updates and discovered devices cards (#55335)
Adds ConnectionSubscriptionController as a context based replacement for
SubscribeMixin. It keeps one websocket subscription alive while the host
is connected and re-evaluates an optional enabled callback after each
update. The repairs and discovered devices cards subscribe through it.
The updates card counts installable updates with a states transform that
waits for the formatters context and evaluates its visibility in
update(), after the transform ran. Its test provides contexts instead of
a hass object.
2026-10-27 15:26:29 +00:00
Petar Petrov 70b86832b2 Drop hass from the device automation pickers (#55302)
The device action, condition and trigger pickers read localization, states, formatters and the API from contexts.
2026-10-27 15:15:06 +00:00
Petar Petrov 2df237975b Drop hass from the object selector (#55315)
formatSelectorValue takes the values it reads, and the object selector consumes them from contexts.
2026-10-27 15:13:32 +00:00
renovate[bot] 60a632f584 Update dependency xss to v1.0.17 (#55354)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-27 16:12:25 +01:00
Aidan Timson 4bdf332f30 Add actions to data table group headers (#55235)
* Add an actions slot to data table group headers

* Dismiss new repositories from the Marketplace group header

* Style group header actions like the rows' icon buttons

* Keep group header actions in view and forward ungrouped slots

* Keep the error alert open when dismissing new repositories fails

* Let pages change the colour of group header actions

* Size group header actions like other header icon buttons

* Keep the pinned group header a table row

* Forward only supplied group actions and index the pinned header
2026-10-27 16:01:59 +01:00
Petar Petrov 73416452ef Drop hass from the mount, serial port, push and vacuum segment components (#55308)
getVacuumSegments, getAppKey, listSerialPorts and fetchSupervisorMounts take callWS, so their components read translations, config, registries and the API from contexts.
2026-10-27 14:23:01 +01:00
Petar Petrov fc3d0ee74b Drop hass from the assist pipeline and conversation agent pickers (#55306)
listAssistPipelines, listAgents, getConfigEntry and getSubEntries take callWS, so both pickers and their selectors read translations, the API, the config and the entity registry from contexts.
2026-10-27 14:22:51 +01:00
Petar Petrov 4952db4960 Drop hass from the label pickers (#55303)
The label registry helpers take callWS, so ha-label-picker, ha-labels-picker and ha-selector-label read their data from contexts.
2026-10-27 14:22:48 +01:00
Petar Petrov 2bdcea4bc0 Drop hass from the entity attribute and state content pickers (#55307)
Both pickers read entity state, formatters, translations and registries from contexts, so the attribute and UI state content selectors stop forwarding hass.
2026-10-27 11:38:29 +01:00
Petar Petrov f2bed231ff Drop hass from the media browser thumbnail (#55305)
The thumbnail reads dark mode, the Home Assistant URL and fetchWithAuth from contexts, and re-resolves brand icons when the theme changes.
2026-10-27 11:38:04 +01:00
Petar Petrov e1178bc80b Drop hass from the speech engine pickers (#55300)
The TTS and STT helpers take callWS instead of hass, so the TTS, STT and TTS voice pickers and their selectors read translations, registries, states and the API from contexts.
2026-10-27 11:37:42 +01:00
Petar Petrov d732af588c Drop hass from the floor pickers (#55311)
createFloorRegistryEntry and updateAreaRegistryEntry take callWS instead of hass, so ha-floor-picker and ha-floors-picker read registries, states, translations and the API from contexts.
2026-10-27 11:37:18 +01:00
Petar Petrov d492ecaefd Drop hass from the entity state pickers (#55313)
getStates takes the states and language, so the state pickers read entity states, formatters and translations from contexts.
2026-10-27 11:37:04 +01:00
WendelinandClaude Opus 5.5 bc6d8841eb Refactor add automation element dialog (#55233)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-27 10:29:41 +01:00
Bram Kragten 901c0a61ad Bumped version to 20260930.5 2026-10-23 15:07:48 +02:00
karwosts f2ad7bbd6a Fix blueprint import redirect links (#55087) 2026-10-23 15:07:04 +02:00
Petar Petrov 25c73b7c0d Fix Sankey labels on thin nodes showing at full size in Safari (#55020) 2026-10-23 15:07:02 +02:00
Raman GuptaandClaude Opus 5.5 41b7c079a8 Fix Z-Wave config parameter status always showing as error (#55002)
The zwave_js/set_config_parameter and set_raw_config_parameter commands
return the zwave-js-server CommandStatus string ("accepted" or
"queued"), not the numeric zwave-js SetValueStatus. #54287 retyped the
status as a number and mapped it through computeSetConfigParamStatus,
which turned every successful update into "error".

Type the status as the string union the backend sends and use it
directly again. The raw variant only returns status, so give it its own
result type without value_id.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-23 15:07:01 +02:00
WendelinandClaude Opus 5.5 e386b091f1 Prevent iOS Safari text inflation on landscape rotation (#54914)
* Prevent iOS Safari text inflation on landscape rotation

Fixes #53060

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CPRiV8TCjj8Z4SX9jX9578

* also add the fix for gallery and demo

---------

Co-authored-by: Claude <[email protected]>
2026-10-23 15:07:00 +02:00
Petar Petrov 52c4f360cc Disable Lit dev mode in the legacy build under pnpm (#54855)
Compile Lit without dev mode in the legacy build again
2026-10-23 15:06:58 +02:00
Bram Kragten 26690e06b0 Bumped version to 20260930.4 2026-10-16 13:23:16 +02:00
Maarten Lakerveld e7a89e0ac0 Stop the user condition editor from duplicating selected users (#54921)
When the users finish loading, the list picks up the pre-selected items and
fires a selected event for them. The handler appended that user again on
every open of the editor, so a single user condition grew one duplicate id
per edit. Ignore selection events that do not change the configured users.

Fixes #54894
2026-10-16 13:22:57 +02:00
Paulus SchoutsenandClaude eb90a50ca1 Keep hidden columns when reopening data table settings (#54874)
Keep column settings for tables without fixed last columns

The data table settings dialog dropped the stored column order when the
table had no lastFixed columns. With no column order, the dialog showed
every column as visible. Reopening the dialog on the statistics table
then showed hidden columns as enabled again.


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

Co-authored-by: Claude <[email protected]>
2026-10-16 13:22:56 +02:00
MichaelandClaude Opus 5.5 e221ea2b19 Fix Matter add device button not opening the add device dialog (#54823)
The add device button on the Matter dashboard linked to
/config/matter/add, which navigated to the devices dashboard and opened
the Matter add device dialog without awaiting the navigation. The
navigation closes open dialogs before it completes, so the dialog was
closed again right away and the user ended up on the devices dashboard.

Open the dialog directly from the Matter dashboard, like the Z-Wave
dashboard does, and make the /config/matter/add route (used by the
add_matter_device my link) await the navigation before showing the
dialog from the app root, as its own element is detached by then.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 13:22:55 +02:00
Jan-Philipp Benecke abd5f9e985 Fix script graph crash on template shorthand conditions (#54798)
* Fix script graph crash on template shorthand conditions

* Move string to ConditionWithShorthand
2026-10-16 13:22:54 +02:00
Petar Petrov 2f9a4f99be Link Ethernet Matter nodes to Home Assistant in the network map (#54784) 2026-10-16 13:22:53 +02:00
Maarten Lakerveld 0cf9fcef97 Map overview fixes (#54774) 2026-10-16 13:21:22 +02:00
Bram KragtenandClaude Opus 5.5 3f7f4613d6 Remove invalid ::part() selector that broke CSS minification (#54772)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:29:05 +02:00
Bram Kragten 4a8501a6e5 Fix import from ui-support 2026-10-09 11:26:27 +02:00
Bram Kragten 4ae1775644 Bumped version to 20260930.3 2026-10-09 11:16:55 +02:00
Jan-Philipp Benecke 80cd338e77 Fix crash on null trigger entries in the automation editor (#54771) 2026-10-09 11:16:35 +02:00
Franck Nijhof cd8f785ce6 Don't count the characters of a string target (#54769) 2026-10-09 11:11:58 +02:00
Franck Nijhof 4f4f7bd633 Translate attribute names every entity can have (#54768)
* Translate attribute names every entity can have

* Translate the supported features attribute name
2026-10-09 11:11:57 +02:00
Franck Nijhof c2413fd7fa Greet in the interface language when it is a variant of the pipeline language (#54766) 2026-10-09 11:11:55 +02:00
Franck Nijhof ed596fe700 Hide hidden cards in grid sections without :has() (#54764) 2026-10-09 11:11:53 +02:00
Franck Nijhof e580fb8b5c Keep dropdown menus clear of safe areas (#54762) 2026-10-09 11:11:52 +02:00
Franck Nijhof 5ba7a906d4 Keep icon button labels off the ha-button host (#54761) 2026-10-09 11:11:51 +02:00
Franck Nijhof c6159615d7 Release streaming images when hui-image is detached (#54760) 2026-10-09 11:11:49 +02:00
Franck Nijhof b2f63cf345 Show the speech-to-text duration without VAD (#54759) 2026-10-09 11:11:48 +02:00
Franck Nijhof fbd7bb073e Give dialogs an accessible name (#54758) 2026-10-09 11:11:47 +02:00
Franck Nijhof 42fec4fb9c Expose the real invalid state of inputs to screen readers (#54757) 2026-10-09 11:11:46 +02:00
Franck Nijhof bcd7f0dc88 Show the ZHA config entry state when it isn't loaded (#54756) 2026-10-09 11:11:45 +02:00
Franck Nijhof 44b5dc9446 Fit the add integration list on short screens (#54755) 2026-10-09 11:11:43 +02:00
Franck Nijhof 4fd8732160 Fall back to local voice setup when the cloud status fails (#54754) 2026-10-09 11:11:42 +02:00
Petar Petrov 51675e0cd2 Fix negative upstream device untracked energy in devices detail graph (#54753) 2026-10-09 11:10:17 +02:00
Petar Petrov 36bddbff06 Hide Zigbee from the ESPHome device setup wizard (#54751) 2026-10-09 11:06:57 +02:00
Simon Lamon e8c15f10e2 Restore compatibillity with older chromium devices in ScrollFadeController (#54742)
Restore compatibillity with older chromium devices
2026-10-09 11:06:55 +02:00
Franck Nijhof 8f870146c6 Keep custom logbook messages for automations and scripts (#54739)
* Keep custom logbook messages for automations and scripts

* Only match the domain's own run message
2026-10-09 11:06:54 +02:00
Franck Nijhof 91c0d052df Convert CSS color names to hex when saving a label (#54738) 2026-10-09 11:06:53 +02:00
Franck Nijhof dfb5e7d768 Keep the label of color inputs raised (#54737) 2026-10-09 11:06:51 +02:00
Artur Pragacz 03fb6f5c29 Fix translation key for external config flow step description (#54722) 2026-10-09 11:06:50 +02:00
Maarten Lakerveld 11176a7593 Center the map on a single entity instead of fitting it (#54718)
The vector map's globe projection (new in 2026.10) cannot solve a camera
for a zero-area bound, so fitting a single-entity map warned "Map cannot
fit within canvas" and left the view on the default location. Center a
single point directly with easeTo/flyTo rather than routing it through
fitBounds, carrying the fit's padding as a transient offset and honouring
its fly request.
2026-10-09 11:06:48 +02:00
Paul Bottein 8a7b8ff2ac Add side padding to the statistics search field (#54717) 2026-10-09 11:06:47 +02:00
Franck Nijhof f61e1ab509 Respect custom icons for built-in panels in the sidebar (#54709) 2026-10-09 11:06:46 +02:00
Franck Nijhof 3e75d97b8a Show the right page title for more settings pages (#54708) 2026-10-09 11:06:45 +02:00
Franck Nijhof 8588d34fbe Only cache the frontend's own static files in the service worker (#54707) 2026-10-09 11:06:43 +02:00
Paul Bottein f4952764d4 Replace map overview more info button with a header icon (#54704)
* Replace map overview more info button with a header icon

* Reuse the show more information label

* Show a settings button for admins in the map overview

* Hide the map overview settings button in the demo
2026-10-09 11:06:42 +02:00
Franck Nijhof 52d38105ae Let long dropdown submenus scroll (#54692) 2026-10-09 11:06:41 +02:00
Franck Nijhof 8fa6c1d027 Keep a cleared input value when focusing it again (#54690) 2026-10-09 11:06:39 +02:00
Franck Nijhof 972b999195 Handle map labels without a name (#54689) 2026-10-09 11:06:38 +02:00
Petar Petrov fe6bc5530e Show the newest history states when the browser clock runs behind (#54688) 2026-10-09 11:06:37 +02:00
Franck Nijhof d5d7093ca3 Keep the key order when duplicating a script (#54687) 2026-10-09 11:06:36 +02:00
Franck Nijhof 52d30d5b6e Keep boolean fields inline in the narrow action editor (#54686) 2026-10-09 11:06:35 +02:00
Franck Nijhof 2a25673659 Fix ZHA reconfigure dialog errors after closing and on reconnect (#54684)
* Fix ZHA reconfigure dialog errors after closing and on reconnect

* Only handle reconfigure messages of the current run
2026-10-09 11:06:33 +02:00
Franck Nijhof 94838cf7d1 Load the HLS source once instead of on every media attach (#54683) 2026-10-09 11:06:32 +02:00
Franck Nijhof 9214bcac94 Wrap descriptions in voice assistant settings (#54678) 2026-10-09 11:06:30 +02:00
karwosts 3be6887df6 Fix create floor memoization issue (#54675) 2026-10-09 11:06:29 +02:00
Paul Bottein 33374693c9 Snap the map overview bottom sheet to fixed positions (#54663)
* Snap the map overview bottom sheet to fixed positions

* Include the safe area in the collapsed stop and follow reduced motion
2026-10-09 11:06:28 +02:00
Marcin BauerandClaude Opus 5.5 80516f1f00 Restore left-column timeline names on the history-graph card (#54661)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:06:26 +02:00
Petar Petrov 9253af6a19 Align history timeline X axis with line charts (#54657) 2026-10-09 11:06:25 +02:00
Bram KragtenandClaude Opus 5.5 58f1d51c7f Add realistic automations to the demo (#54647)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:04:03 +02:00
Bram KragtenandClaude Opus 5.5 05d6f614bc Show the next demo card on the home page demo again (#54646)
* Show the next demo card on the home page demo again

The home overview strategy now generates the favorites section itself
instead of returning a common-controls section strategy, so the demo
strategy no longer found the section to add its card to. Find the
favorites section by its heading instead.

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

* Keep at least six favorites next to the demo card

The section strategy used to get a limit of two less, so seven tiles
became six. Only drop as many tiles as needed to keep six.

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

* Keep the current user when the demo user subscription refreshes

The auth/current_user mock answered with the user from startup, so the
user subscription reset a changed user, like the non-admin scenario of
the e2e tests, to the initial admin user.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:44 +02:00
Bram KragtenandClaude Opus 5.5 6e2bb1b2f5 Mock editing the own profile in the demo (#54645)
The demo did not answer person/update_own_profile, so saving the profile
dialog showed an error. Also answer auth/current_user, so the user
subscription picks up the new name.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:43 +02:00
Bram KragtenandClaude Opus 5.5 f3a6e1028e Add focus indicator to entity and sensor cards (#54644)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:41 +02:00
WendelinandClaude f182b4600d Dismiss reboot/shutdown toast when frontend reconnects (#54640)
The "Rebooting system" toast was shown without an id and with no
timeout, so nothing could ever close it and it stayed on screen after
the host came back. Give it an id, dismiss it when the connection is
re-established, and also when the action fails with an error dialog.

Fixes #54630


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

Co-authored-by: Claude <[email protected]>
2026-10-09 11:00:40 +02:00
Jan Čermák b7983e0906 Copy locale data to the landing page build (#54488)
The landing page bundle includes the Intl polyfill. When a browser
needs a polyfill, it loads locale data from /static/locale-data/.
The landing page build never copied that directory, so these requests
returned 404. The language picker then showed raw language codes.

Fixes home-assistant/landingpage#216
2026-10-09 11:00:38 +02:00
Bram KragtenandClaude Opus 5 6c988150e4 Draw the globe against a sky when the map is zoomed out (#54485)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:37 +02:00
Bram KragtenandClaude Opus 5.5 b88a39e273 Cache map tiles per asset class and stop refetching what still works (#54451)
* Cache map tiles per asset class and stop refetching what still works

The service worker kept glyphs, sprites and tiles in one 500-entry LRU, so
panning around evicted the fonts that every label on every map needs. Glyphs
and sprites move to a cache of their own, on the 30 day age core already sends
them with, and the tile ceiling goes up.

The TileJSON was deliberately left on the network because it is the switching
point for the tile source. Every map waits on it before asking for a tile
though, and at a five minute max-age that is a round trip before the first one.
Served stale-while-revalidate instead: out of the cache immediately, refreshed
behind it, so a moved endpoint still arrives.

The raster fallback redrew on every token rotation, refetching every tile on
screen twice an hour whether or not anything had failed. Later tiles pick up a
new token by themselves, so only a layer that actually had one refused needs
asking again - and since a refused tile is the only sign that path gets that
its token is stale, it now asks for a new one instead of waiting for the
refresh interval to come round.

Style requests that are in flight are shared, so a dashboard of maps, or a
theme toggle switching all of them at once, fetches each style once. Only while
in flight: a settled request is dropped, or a style that failed once would
never be fetched again.

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

* Hand the raster fallback the current map tiles token

A rotation is only announced once, so a vector layer that later falls back
handed the raster layer the token it was created with, and the refresh that
follows a refused tile fetches the already-current token and announces
nothing. The vector layer now tracks rotations itself.

Also covers in-flight style sharing, and passes a map style rather than
`false` in the raster recovery tests.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-09 11:00:35 +02:00
Maarten Lakerveld ec6b258750 Zone editor: fit without passive zones, hide covered radius handles, name tooltips, and start new zones at the map center (#54597)
* Leave passive zones out of the zones map fit

Opening the zones page fitted the map around every zone, so a few remote
passive zones pushed the view out to the whole globe and adding a zone took
a lot of zooming. Passive zones are still drawn and editable, but no longer
count toward the fit unless they are the only zones there are. Editable
locations gained a fit flag for this.

Fixes #54595

* Hide a zone's radius handle while the circle is under its icon

Zoomed out on the zones page, every zone's radius handle collapsed onto the
zone icon and covered it. The handle now hides while the radius on screen is
shorter than the icon's half width, so it returns as soon as the circle's
edge shows past the icon, and it stays while the radius is being dragged.

* Show a zone's name in a tooltip when hovering its marker on the zones map

The marker only had a native title, which appears late and in the
browser's own style. Every named editable location now gets an ha-tooltip
anchored to its element, so hovering a zone icon on the zones page names
it promptly; the native title is dropped so the two do not stack.

* Start a new zone at the center of the zones map

Create zone placed the new zone at a random spot up to 500 meters from
home, so it landed somewhere unexpected and often off-screen. When the zones
page has its map open, a new zone now starts at the center of the current
view, so panning to the right place first works as expected. A location
handed in, as from a person's Create zone action, still wins; without a map
the random offset around home remains.
2026-10-07 10:12:54 +02:00
Maarten Lakerveld ea1fabdfdd Map: float every marker, keep the selection in the URL, pan-first focus, and fix attribution and zone radius display (#54596)
* Auto-hide compact map attribution and restyle it for dark mode

MapLibre decides compact mode from the map canvas width alone (640px), so
shrinking the window flipped the attribution from compact back to inline
when the sidebar became a drawer and the map got wider. The engine now adds
the control itself and forces compact on a narrow viewport (870px, the app's
own narrow layout), swapping it when the breakpoint changes.

MapLibre only collapsed the opened compact attribution on a drag; it now
also collapses by itself after five seconds, unless the user opened it from
the info button.

MapLibre's stylesheet hardcodes a white attribution with black text, which
Home Assistant never overrode, so it stood out on a dark map. The dark mode
rules now match the zoom control styling, and the inline text color is set
explicitly instead of inherited from a page that may be in the other mode.

* Add show_zone_radius option to the map card

In panel layout the map hides zone radii except on the Zones tab, for the
selected zone, and while editing. A user asked to always see them, so the
new option draws the radius of every visible zone on all tabs. Other layouts
already show radii and are unaffected.

* Regroup map clusters up front for every fit

Clicking a bubble already regrouped its members at the zoom the fit would
land on, so they parted as the animation started. Any other way of zooming
in, such as picking a person in the map overview, waited for the move to end
before the bubble split. The fit itself now does the regrouping, for all
markers, so every animated fit shows the bubbles as they will end up.

* Return the map to its previous view from the overview's back button

Leaving a selected person or zone cleared the selection but left the map
zoomed in on it. The card now remembers where the map was when the overview
drilled in: the exact camera, or the previous entity's fit when one selection
followed another, and the back button eases back there. A camera that was
still auto-fitting resumes auto-fit instead of freezing. Clicking the map to
deselect stays put, since that is the user looking around.

Engines gain getView, and setView can animate.

* Float the selected map marker above a dot at its location

Selecting a person or device marked it with a ring only, and inside a zone
it stayed hidden in the zone's bubble. The selected marker is now excluded
from clustering, drawn a quarter larger, and hangs above a small dot at the
exact coordinate with a tail pointing down to it. The accuracy circle around
the dot carries the entity color, so the ring is dropped; an entity that
reports no accuracy gets a nominal 5 meter circle so the color still shows.
A fix of 10 meters or better draws that circle as a soft disc without an
outline, since a hard edge would claim more precision than is known; map
circles gained an outline option for this. The engine's marker anchor puts
the dot on the coordinate; both engines support it. Bubble avatars keep
their size.

* Open more info from a button in the map overview detail

The selected person, device or zone's name was the only way to the more-info
dialog, which made the title read as a link. The name is plain text now and
a filled More info button sits below the activity, where the user expects an
action. Focus still moves to the name when the detail view opens.

* Always show the selected marker's accuracy circle

The circle only appeared for a GPS fix looser than 100 meters, so most
phones never showed one. The selected marker now floats above a dot at its
coordinate, so even a small circle around that dot reads clearly; it is
never hidden for the selected entity. Focusing still fits the circle into
view only above 100 meters, since a tighter circle fits at the focus zoom.

* Describe every toggle in the map card editor

Only Show all and Show zone radius explained themselves; Scale ruler, Auto
fit, Fit zones and Cluster markers now have helper text too.

* Float every map marker above its location

Only the selected marker hung above a dot with a tail; the rest sat
centered on their coordinate, so a marker's frame always hid the spot it
marked. Every entity marker is now a pin whose tail points just above its
coordinate. Selecting one scales its frame up and shows the dot at the
exact location. Cluster bubble avatars keep their plain frames.

* Keep the map overview selection in browser history

The card remembered a single camera to restore from the overview's back
button, so after selecting one entity and then another the original view
was lost, and back from the second left the map zoomed on the first while
the overview showed the list. The selected entity now lives in the URL as
an entity_id query parameter; selecting pushes a history entry, and so
does deselecting, whether from the overview's back arrow or a click on
the map, so the browser's back and forward step through every selection.
The overview's arrow itself always returns to the list. Selecting focuses
the entity; deselecting leaves the map where it is. Only selectable
domains are accepted from the URL; anything else is dropped from it. The
saved camera, the animated setView and the autoFit view flag had no
other users.

* Pan to a selected map entity instead of zooming in on it

Focusing a person jumped to zoom 19 and a zone fitted its circle, which
often zoomed much further in than the user had been. Selecting now keeps
the current zoom and pans the entity into the free part of the view. It
only zooms in when another marker sits within 80 screen pixels, and then
just far enough to clear it, capped at zoom 17. Markers at the very same
position cannot be separated by zooming and are left alone. A zone is
fitted as its whole circle with a little room around it under that same
zoom cap, so a zone that fits the view keeps the zoom and a bigger one
zooms out just enough. A marker already on screen eases over; one off
screen flies there in an arc. Fits gain a fly option for this, and every
other fit now eases straight to its bounds on both engines, where
MapLibre used to fly. Map-engine gains pixelDistance, the Web Mercator
screen distance at a zoom level, with a test for its scale.

* Float every cluster bubble above its location

Only a bubble attached to a zone had a tail and hung above the zone's
circle; a bubble of nearby markers sat centered on the cluster, hiding the
spot it stood for. Every bubble now has the tail and floats above its
location with the same lift as a single marker's pin, so the two read as
one style. A zone's bubble keeps floating above the zone circle.
2026-10-07 10:12:53 +02:00
Bram Kragten af4f86743b Bumped version to 20260930.2 2026-10-07 10:07:02 +02:00
Artur Pragacz ed492d5b94 Consider next name part when unprefixing entity name (#54633)
* Consider next name part when unprefixing entity name

* Adjust
2026-10-07 10:05:34 +02:00
Paul Botteinandcopilot-swe-agent[bot] db00a6b7e7 Fix section editor crash while typing a strategy in YAML (#54622)
* Fix section editor crash while typing a strategy in YAML

* Reject array-valued section strategies in YAML

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-07 10:05:33 +02:00
WendelinandClaude 864f0877f6 Increase touch area of ha-checkbox (#54617)
Co-authored-by: Claude <[email protected]>
2026-10-07 10:05:31 +02:00
Wendelin d7062f3d9d Fix HaChipBase border radius (#54603)
Add border-radius to HaChipBase styles for improved appearance
2026-10-07 10:05:30 +02:00
Jonathan 3051032360 Show the requested run when a trace page switches item (#54591) 2026-10-07 10:05:29 +02:00
Paul Bottein 54c575efd1 Show referenced triggers instead of generated IDs in automation and trace descriptions (#54587) 2026-10-07 10:05:28 +02:00
Paul Bottein e6bc47e6ba Treat an entity name equal to its device name as no own name (#54585)
* Treat an entity name equal to its device name as no own name

* Use the same device name check when computing entity names

* Only check the user-set name against the device name

* Add tests for the device name check

* Clarify why only the user-set name is matched loosely
2026-10-07 10:05:26 +02:00
WendelinandClaude Sonnet 5.5 6244a8db04 Fix config entry state styling (#54584)
* Restore needs attention color on config entry rows

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Restore config entry row state styling

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Highlight config entry state with border color instead of tint

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Add quiet fill background to config entry states

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic colors for config entry state borders

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Drop state border colors and use semantic text colors

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic color for config entry link icon

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Add divider between discovered and attention flow rows

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic border and fill colors for discovered and attention lists

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use warning colors for discovered list too

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use standard divider color for flow list borders

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use border width token for flow list borders

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Restore warning icon color for failed unload state

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* review

---------

Co-authored-by: Claude <[email protected]>
2026-10-07 10:05:25 +02:00
Paul Bottein 3f2c1ea538 Show then branch before else in trace graph (#54583) 2026-10-07 10:05:24 +02:00
Paul Bottein aac947ee4d Fix option labels overflowing horizontal control selects (#54582) 2026-10-07 10:05:23 +02:00
Maarten Lakerveld 594d9be859 Bump home-assistant-js-websocket to 9.7.1 (#54577)
Exclude our own packages from the 3-day minimumReleaseAge rule so
fixes we publish ourselves can be picked up immediately. The
no-downgrade trust policy still rejects unsigned releases.
2026-10-07 10:05:22 +02:00
Paul Bottein 80ae2543e5 Fix map history paths flickering on every update (#54573)
Update map paths and zone circles in place instead of redrawing them
2026-10-07 10:05:21 +02:00
Wendelin 94d176c3d1 Fix styling for config entry device rows (#54570) 2026-10-07 10:05:20 +02:00
Jan-Philipp Benecke 192d9f785f Hide dangling trace graph connectors when nothing follows (#54568) 2026-10-07 10:05:18 +02:00
Paul Bottein 398ffa045b Fix first badges cut off in centered scrolling view header (#54565) 2026-10-07 10:05:17 +02:00
Wendelin e67538fb7c config-area-page fix missing list-item import (#54563) 2026-10-07 10:05:16 +02:00
Bram Kragten 025db7ffe7 Bumped version to 20260930.1 2026-10-05 11:02:45 +02:00
Paulus SchoutsenandClaude f9c958c80c Hide MCP URL hint until the server is enabled (#54532)
When the MCP server is not set up, the card has no URLs to show.
The description told the user to add one of the URLs below. Show
only the documentation link until the server is enabled.


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

Co-authored-by: Claude <[email protected]>
2026-10-05 11:02:26 +02:00
Jan-Philipp Benecke 652897a3cb Handle undefined trigger condition IDs in rendering (#54527)
* Handle undefined trigger condition IDs in rendering

* Handle trigger conditions without ID in editor and updates
2026-10-05 11:02:25 +02:00
Maarten Lakerveld 3426449315 Keep padded map fits within the poles (#54526)
The MapLibre engine pads a fit by a fraction of its span. A zone whose
circle reaches a pole, or markers far apart, pushed the padded
latitude past 90, and MapLibre throws on such bounds, so the fit never
happened and the console filled with errors. The padded latitude is
now clamped to the poles.

Fixes #54498
2026-10-05 11:02:24 +02:00
Jan-Philipp Benecke 8e19b0249d Fix misplaced trace graph icons on iOS (#54520) 2026-10-05 11:02:23 +02:00
Paul Bottein bba034eeca Show entered and left in map zone activity (#54516)
* Show arrived and left in map zone activity

* Use entered instead of arrived in map zone activity
2026-10-05 11:02:21 +02:00
karwosts 31f2767038 Remove spurious color clear button (#54515)
* Improve number selectors in section settings

* Fix sections editor default color
2026-10-05 11:00:21 +02:00
Petar Petrov e0571044ae Detect the ESPHome Z-Wave discovery flow by the device MAC (#54513) 2026-10-05 10:57:27 +02:00
Maarten Lakerveld 0f00286641 Fix map overview tabs, activity timeline, cluster counts, zone fit and the new-zone route (#54512)
* Show four avatars in a cluster bubble before counting

A bubble capped at three avatars added a +1 chip for the fourth
member, taking a fourth slot to hide one avatar. Four members now show
as four avatars; from five on, three avatars and a count fill the same
four slots.

* Open the new zone dialog on every visit to /config/zone/new

The route was only handled in firstUpdated. The config panel is kept
alive between visits and the router reuses the zone page when it is
already the current one, so "Create zone from current location" did
nothing once the zone list had been opened earlier in the session. The
route is now handled on every route change, like /edit/ already was.

The dialog also opened before the more-info dialog it was launched
from had finished leaving the history, and that departure closed the
new dialog again. The page now awaits the replace navigation, which
waits for closing dialogs, before opening it.

* Hide the map overview while the dashboard is being edited

In panel view edit mode the map card kept rendering its overview. On
phones that is a bottom sheet fixed over the bottom of the screen,
which covered the card's edit bar and the view's add button, so the
map dashboard could not be edited in portrait. The overview is for
using the map, not configuring it, so it is left out while editing.

* Show only the overview tabs that have something to list

People and Zones were always offered, with an empty message when the
map card's entities held none of them; only Devices was hidden when
empty. All three now appear only with entries, the first remaining tab
takes over when the selected one empties, and the overview tells the
card so its marker filter follows. The empty-tab messages are gone
with the empty tabs.

* List the person of the current user first, as "Me"

The overview's People tab sorted everyone alike. The person linked to
the user looking at the map now comes first and is named "Me" in the
list and the detail header, through a localized string. Their initials
still come from their real name.

* Draw the overview activity as a timeline

The activity list reused logbook rows: the person's name on every row,
the state pushed to the right, dividers between rows. It is now a
timeline of its own. Each row names where the person went, with the
clock time and how long ago underneath, hung off a dot in the zone's
color with a rail to the next row. A zone's rows name who came or
left and where they are. Five rows show at first, with a "Show more"
button for the rest. The overview no longer needs the broad hass
object, which only the logbook row took.

* Fit the map to its zones when they are all it shows

Zones only joined the auto-fit with the fit_zones option. The panel
map does not set it, so on the Zones tab, with no people or devices
left, the fit found nothing and jumped straight to the home
coordinates at the default zoom. Zones now stand in whenever nothing
else is on the map, which goes through the animated fit like the
other tabs. With fit_zones they still join a fit of people and devices.
2026-10-05 10:57:26 +02:00
Petar Petrov 9cc166d3cf Fix double side padding around more-info history and activity (#54511) 2026-10-05 10:57:25 +02:00
Bram Kragten 3ad1ed5aa6 Resolve split devices in the logbook card (#54507) 2026-10-05 10:57:24 +02:00
Wendelin 8ee84c2462 Use optional short issue title localization to repairs dialog (#54506)
Add short issue title localization to repairs dialog
2026-10-05 10:57:22 +02:00
Paul Bottein eca108032f Fix area switch for entities without their own name (#54503) 2026-10-05 10:57:21 +02:00
Paul Bottein 3bfe67f784 Give zones their own map colors and prefer white zone icons (#54499)
* Give zones their own map color sequence

* Prefer white zone icons when contrast allows
2026-10-05 10:57:20 +02:00
Paul Bottein c27191de6b Show people and zones on the demo map (#54496) 2026-10-05 10:57:19 +02:00
Bram Kragten 31f626c877 Bump saturation of the default light mode map (#54492)
bump saturation of the default light mode map
2026-10-05 10:57:17 +02:00
Tom Mittelstädtand1358 b081db7020 Give tentative calendar events a striped background (#54483)
* Give tentative calendar events a striped background

A tentative bar gets a light fill in its calendar color with diagonal stripes in the text color, instead of the outline alone. A tentative dot keeps its color and size and gets stripes in the calendar's contrast color. The stripes set a tentative event apart from past days, which are already drawn faded. They repeat on whole pixels, so every stripe looks the same.

* Strengthen tentative dot stripes and add a forced-colors fallback

The stripes on a tentative dot are its only cue, so they now reach at least 3:1 against the dot. In forced colors the stripes and the dot's fill are dropped, so a tentative bar falls back to a dashed border and a tentative dot to a ring of the same size.

---------

Co-authored-by: 1358 <[email protected]>
2026-10-05 10:57:16 +02:00
792 changed files with 33095 additions and 11947 deletions

No files matched your search

+31 -1
View File
@@ -1,6 +1,6 @@
---
name: ha-frontend-components
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, loading states, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
---
# HA Frontend Components
@@ -91,6 +91,36 @@ html`
`;
```
## Loading States
Pick the indicator by what is loading, following the [Loading pattern](https://contributors.home-assistant.io/design/patterns/loading/):
- Use a skeleton from `src/components/skeleton/` (`ha-skeleton`, `ha-skeleton-text`, `ha-skeleton-icon`) when content loads into a layout you already know, such as table rows, tile text, or a log. It holds the layout, so nothing jumps when the content arrives.
- Use `ha-spinner` only when a whole area loads and its shape is unknown.
- Use `ha-button` with `loading`, or `ha-progress-button`, for an action started from a button. Don't put a separate spinner next to it.
Wrap the indicator in `ha-fade-in` from `src/components/animation/ha-fade-in.ts`:
- Fade a skeleton in straight away over about 300ms (`.duration=${300}`), as the data table does. It stands in for the content, so a delay would leave an empty gap.
- Delay a spinner or progress bar, usually by `.delay=${500}`, so a load that finishes within that time never flashes it. Some slower areas, such as backups and updates, use `${1000}`.
- Don't fade in the loaded content itself.
Give a skeleton group the `progressbar` role and a translated label, as `ha-spinner` has, rather than visible "Loading" text. A loading state always ends in the content, an empty state, or an error.
```ts
html`
<ha-fade-in .duration=${300} easing="ease-in">
<div
role="progressbar"
aria-label=${this._i18n.localize("ui.common.loading")}
>
<ha-skeleton-text></ha-skeleton-text>
</div>
</ha-fade-in>
<ha-fade-in .delay=${500}><ha-spinner></ha-spinner></ha-fade-in>
`;
```
## Shortcuts And Tooltips
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
+4
View File
@@ -12,3 +12,7 @@
demo/public/api/camera_proxy_stream/* binary
demo/public/api/media_player_proxy/* binary
# Generated by `gh actions-lock`. Highlight it as YAML and don't auto-merge it:
# on a conflict, restore it from the base branch and run `gh actions-lock`.
.github/workflows/actions.lock linguist-language=YAML -merge
+5 -3
View File
@@ -51,8 +51,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to [documentation][docs-repository] pull request:
- Link to [contributors documentation][contributors-repository] pull request:
- Link to backend pull request:
## Checklist
@@ -63,7 +63,8 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
understanding the code before submitting a PR. Please follow our AI policy:
https://contributors.home-assistant.io/start/ai-policy/
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -105,4 +106,5 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
+6
View File
@@ -0,0 +1,6 @@
paths:
.github/workflows/**/*.{yml,yaml}:
ignore:
# actionlint doesn't support `$/` self-repository actions yet, remove once it does:
# https://github.com/rhysd/actionlint/issues/711
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
@@ -24,7 +24,7 @@ runs:
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
cache: false
+41
View File
@@ -0,0 +1,41 @@
name: Verify actions lockfile
on:
push:
branches:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
pull_request:
branches:
- dev
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
name: Verify actions lockfile
runs-on: ubuntu-latest
env:
GH_ACTIONS_LOCK_VERSION: v0.4.1
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
env:
GH_TOKEN: ${{ github.token }}
run: gh extension install github/gh-actions-lock --pin "${GH_ACTIONS_LOCK_VERSION}"
- name: Verify lockfile
env:
GH_TOKEN: ${{ github.token }}
run: gh actions-lock --verify
+190
View File
@@ -0,0 +1,190 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/actionlint.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/actions-lock.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/cast_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/ci.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/codeql-analysis.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
'.github/workflows/copilot-setup-steps.yml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/demo_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/dependency-review.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/design_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/design_preview.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/e2e.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/labeler.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
'.github/workflows/lock.yaml':
- 'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c'
'.github/workflows/nightly.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/pull-request-standards.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/relative-ci.yaml':
- 'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60'
'.github/workflows/release-drafter.yaml':
- 'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0'
'.github/workflows/release.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361'
- 'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
- 'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086'
'.github/workflows/restrict-task-creation.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/stale.yaml':
- 'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93'
'.github/workflows/sync-device-classes.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/sync-sensor-entity-constants.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/translations.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
dependencies:
'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9':
ref: 'v6.1.0'
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294':
ref: 'v5.0.0'
commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294'
owner_id: 44036562
repo_id: 476372928
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3':
ref: 'v9.0.0'
commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3'
owner_id: 44036562
repo_id: 205262760
'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13':
ref: 'v7.0.0'
commit: 'sha1-bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
owner_id: 44036562
repo_id: 177139928
'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0':
ref: 'v7.1.0'
commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
owner_id: 44036562
repo_id: 192625525
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405':
ref: 'v6.2.0'
commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405'
owner_id: 44036562
repo_id: 192625525
'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93':
ref: 'v11.0.0'
commit: 'sha1-4391f3da665fdf50b6810c1a66712fb9ba21aa93'
owner_id: 44036562
repo_id: 200437066
'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c':
ref: 'v6.0.2'
commit: 'sha1-89ae32b08ed1a541efecbab17912962a5e38981c'
owner_id: 7513068
repo_id: 113918130
'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370':
ref: 'v4.39.1'
commit: 'sha1-9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
owner_id: 9919
repo_id: 259445878
'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361':
ref: 'master'
commit: 'sha1-86d4564712fa02543c808e3a693dd04f9d3f8361'
owner_id: 13844975
repo_id: 173703505
'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716':
ref: '2026.07.0'
commit: 'sha1-9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
owner_id: 13844975
repo_id: 184234812
'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1':
ref: 'v8.1.1'
commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1'
owner_id: 18365890
repo_id: 197176365
'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086':
ref: 'v1.14.3'
commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086'
owner_id: 647025
repo_id: 178055147
uses:
- 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405'
'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60':
ref: 'v3.2.4'
commit: 'sha1-711f3a8fa1b928e6afde280011110c9b0a224a60'
owner_id: 43522883
repo_id: 398376354
'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0':
ref: 'v7.10.0'
commit: 'sha1-978bb0b30c5ee8cdd332ced253758157150d43f0'
owner_id: 58292376
repo_id: 139022359
+6 -6
View File
@@ -30,17 +30,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Cast
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: cast/dist
alias: dev
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Cast
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: cast/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+5 -5
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
uses: $/.github/actions/prepare-dependencies
lint:
name: Lint and check format
@@ -43,7 +43,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
@@ -89,7 +89,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Build resources
@@ -110,7 +110,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
@@ -127,7 +127,7 @@ jobs:
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:javascript-typescript"
+38 -3
View File
@@ -22,10 +22,45 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
# These steps mirror .github/actions/setup with node-modules-cache enabled.
# Copilot runs this workflow in a context that can't resolve `$/`
# self-repository actions, so the steps are inlined here.
- name: Restore complete dependency tree
id: dependency-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
node-modules-cache: true
path: |
node_modules
~/.local/share/pnpm/store
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".nvmrc"
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store directory
id: pnpm-store
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
if: steps.pnpm-store.outputs.path
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-${{ runner.arch }}-
- name: Install dependencies
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
- name: Skip fetching nightly translations
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
+6 -6
View File
@@ -31,17 +31,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Demo
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Demo
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -25,17 +25,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Gallery
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: gallery/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -29,17 +29,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Gallery
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy preview to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: gallery/dist
alias: deploy-preview-${{ github.event.number }}
+16 -16
View File
@@ -32,13 +32,13 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: ./.github/actions/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.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
defaults:
run:
@@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
uses: $/.github/actions/prepare-dependencies
with:
node-modules-cache-key: node-modules-container-v1
@@ -66,12 +66,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Build demo
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-demo-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -97,12 +97,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Build e2e test app
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-e2e-test-app-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -128,12 +128,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Build gallery
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001 --ipc=host
defaults:
run:
@@ -176,7 +176,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001 --ipc=host
defaults:
run:
@@ -230,7 +230,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001 --ipc=host
defaults:
run:
@@ -284,7 +284,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -326,7 +326,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
+3 -3
View File
@@ -26,12 +26,12 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
immutable: false
@@ -124,7 +124,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
immutable: false
+5 -5
View File
@@ -31,15 +31,15 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
uses: home-assistant/actions/helpers/verify-version@86d4564712fa02543c808e3a693dd04f9d3f8361 # master
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
immutable: false
cache: false
@@ -85,7 +85,7 @@ jobs:
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
with:
skip-existing: true
@@ -146,7 +146,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
immutable: false
cache: false
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
+1 -1
View File
@@ -1 +1 @@
24.22.1
24.22.2
+1 -1
View File
@@ -40,7 +40,7 @@ Never run `tsc` or `pnpm lint:types` with file arguments. When `tsc` receives fi
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-components`: dialogs, forms, alerts, loading states, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-events`: event handler typing, custom event dispatch, and event-map declarations.
- `ha-frontend-types`: backend data contracts, optional schemas, shared types, assertions, and lifecycle types.
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
+2 -2
View File
@@ -2,13 +2,13 @@
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 627654,
"core": 55147,
"core": 57901,
"authorize": 593862,
"onboarding": 709255
},
"frontend-legacy": {
"app": 724225,
"core": 157026,
"core": 163115,
"authorize": 693913,
"onboarding": 812415
}
+24 -8
View File
@@ -117,6 +117,16 @@ module.exports.swcOptions = () => ({
},
});
const corejsPolyfills = (exclude = []) => [
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
exclude,
},
];
module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
babelrc: false,
compact: false,
@@ -138,14 +148,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
// Inject Core-JS polyfills on demand. Babel 8 removed preset-env's
// `useBuiltIns`/`corejs` options, so the equivalent polyfill provider is
// configured directly here (`usage-global` matches the old `useBuiltIns: "usage"`).
[
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
},
],
corejsPolyfills(),
[
path.join(BABEL_PLUGINS, "inline-constants-plugin.cjs"),
{
@@ -191,6 +194,16 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
].map((p) => new RegExp(`/node_modules/${p}/`)),
],
},
{
// It only constructs `Uint8Array`s, but any reference to one pulls in
// every typed array polyfill, and this lands in the `core` entrypoint.
test: /[\\/]node_modules[\\/]home-assistant-js-websocket[\\/]/,
plugins: [
corejsPolyfills([
/^(es|esnext)\.(array-buffer|typed-array|uint8-array)\./,
]),
],
},
{
// Use unambiguous for dependencies so that require() is correctly injected into CommonJS files
// Exclusions are needed in some cases where ES modules have no static imports or exports, such as polyfills
@@ -271,6 +284,7 @@ module.exports.config = {
name: "demo" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
embed: path.resolve(paths.demo_dir, "src/embed/entrypoint.ts"),
...module.exports.mapWorkerEntry(),
},
outputPath: outputPath(paths.demo_output_root, latestBuild),
@@ -278,6 +292,8 @@ module.exports.config = {
defineOverlay: {
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
__DEMO__: true,
// The embed sets the global to load static files from its own server.
__STATIC_PATH__: '(globalThis.__HA_STATIC_PATH__ || "/static/")',
},
isProdBuild,
latestBuild,
+5 -1
View File
@@ -232,7 +232,11 @@ gulp.task(
)
);
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
const DEMO_PAGE_ENTRIES = {
"index.html": ["main"],
"components.html": ["embed"],
"embed.js": ["embed"],
};
gulp.task(
"gen-pages-demo-dev",
+18 -8
View File
@@ -214,16 +214,18 @@ const createRspackConfig = ({
},
},
{
// MapLibre's worker loads ESM plugins through `import(url)`, and the
// page side builds a blob import from `new URL(url, import.meta.url)`
// for a cross-origin worker. Neither path is taken here: the RTL
// plugin is UMD and the worker is served from the page's origin.
// Rspack's stubs for these fully dynamic requests are exactly what
// should remain - rejecting, and free of syntax the legacy floor
// cannot parse - so the warnings about them are noise.
// MapLibre's worker loads ESM plugins through `import(url)`, which
// is never taken here: the RTL plugin is UMD. Rspack's stub for that
// fully dynamic request is exactly what should remain - rejecting,
// and free of syntax the legacy floor cannot parse - so the warning
// about it is noise. The page side builds a blob import from
// `new URL(url, import.meta.url)` for a worker on another origin,
// as in the demo embed. That is a URL, not an asset to bundle, and
// the worker URL is absolute, so leave it as it is.
test: /[\\/]maplibre-gl[\\/]dist[\\/]maplibre-gl(?:-worker)?\.mjs$/,
parser: {
exprContextCritical: false,
url: false,
},
},
{
@@ -246,7 +248,15 @@ const createRspackConfig = ({
},
],
},
],
// The demo embed reads its own URL at runtime to find its files.
// Keep import.meta.url instead of replacing it with the source path.
// Development builds wrap modules in eval(), which rejects import.meta.
latestBuild &&
isProdBuild && {
test: path.resolve(paths.demo_dir, "src/embed/public-path.ts"),
parser: { importMeta: false },
},
].filter(Boolean),
},
optimization: {
minimizer: [
+1 -2
View File
@@ -1,4 +1,3 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list";
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
@@ -215,7 +214,7 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
+7
View File
@@ -16,3 +16,10 @@
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
Access-Control-Allow-Origin: *
/static/*
Access-Control-Allow-Origin: *
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from "@lit/context";
import type { HomeAssistant } from "../../../src/types";
export const embedHassContext = createContext<HomeAssistant>("embedHass");
+25
View File
@@ -0,0 +1,25 @@
import "./public-path";
import "./workers";
// The provider must be defined first. Custom elements upgrade in the order
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
// For custom cards, which can't load them themselves. The built-in cards only
// load ha-list-item-button with the map card. The full app loads ha-select
// with the more info dialog.
import "../../../src/components/item/ha-list-item-button";
import "../../../src/components/ha-select";
import { fontStyles } from "../../../src/resources/roboto";
// Font faces only work in the document, not in the shadow root of the card.
// Defining Roboto does not change the page unless it already uses Roboto.
document.head.append(
Object.assign(document.createElement("style"), {
textContent: fontStyles.cssText,
})
);
// Custom cards use the helpers to create built-in cards. The dashboard panel
// sets the same global in the full app.
(window as any).loadCardHelpers = () =>
import("../../../src/panels/lovelace/custom-card-helpers");
+218
View File
@@ -0,0 +1,218 @@
import { ContextEvent } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
import { embedThemeStyles } from "./theme-styles";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so a card inside another component still finds
// the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
/**
* Renders a Lovelace card. The card uses the provider with the ID in
* `provider`, else the closest provider around it, else the first provider on
* the page. If the page has no provider, the card adds an empty one.
*
* The card takes the columns and rows it would take in a section, so cards
* in an element with `display: grid` and 12 columns lay out like a section.
* It also sets `--column-size` and `--row-size` on itself.
*/
@customElement("ha-embed-card")
export class HaEmbedCard extends LitElement {
/**
* The card configuration. Also read from a JSON or YAML script child, see
* `readScriptData`.
*/
@property({ type: Object }) public config?: LovelaceCardConfig;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
private _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// card and its content. Context requests only reach ancestors by themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
private _card = createRef<HuiCard>();
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const node = (ev.composedPath()[0] as HTMLElement | undefined) ?? this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the card keeps a config set since
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const config = readScriptData(this) as LovelaceCardConfig | undefined;
if (config) {
this.config = config;
}
}
protected render() {
if (!this._hass || !this.config) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: this._hass.themes.darkMode })}
dir=${computeRTLDirection(this._hass)}
>
<hui-card
${ref(this._card)}
.hass=${this._hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>
</div>`;
}
protected updated() {
// The card element only exists once hui-card has updated
this._card.value?.updateComplete.then(() => this._updateSize());
}
// The size the card gets in a section, set on the host so that the page can
// lay out cards in a grid of 12 columns, like a section
private _updateSize = () => {
const card = this._card.value;
if (!card) {
return;
}
const { rows, columns } = computeCardGridSize(card.getGridOptions());
this.hidden = card.hidden;
this.toggleAttribute("full-width", columns === "full");
this.toggleAttribute("fit-rows", typeof rows === "number");
this.style.setProperty(
"--column-size",
typeof columns === "number" ? String(columns) : null
);
this.style.setProperty(
"--row-size",
typeof rows === "number" ? String(rows) : null
);
};
static styles = [
embedThemeStyles,
css`
/* The sizes of a card in a section, see hui-grid-section */
:host {
--row-gap: var(--ha-section-grid-row-gap, 8px);
--row-height: var(--ha-section-grid-row-height, 56px);
display: block;
grid-row: span var(--row-size, 1);
grid-column: span min(var(--column-size, 12), 12);
}
:host([full-width]) {
grid-column: 1 / -1;
}
:host([hidden]) {
display: none;
}
:host([fit-rows]) .theme {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
--row-gap
)
);
}
/* The text styles the app sets on html and body, so that the card does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the card */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-card": HaEmbedCard;
}
}
+293
View File
@@ -0,0 +1,293 @@
import { ContextProvider, provide } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { listenMediaQuery } from "../../../src/common/dom/media_query";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { reducedMotionContext } from "../../../src/data/context";
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { provideHass } from "../../../src/fake_data/provide_hass";
import type { DemoArea } from "../stubs/area_registry";
import { toAreaRegistryEntry } from "../stubs/area_registry";
import { mockAuth } from "../stubs/auth";
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../stubs/calendar";
import { extEntry, newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
import { mockLogbook } from "../stubs/logbook";
import { mockMediaPlayer } from "../stubs/media_player";
import { mockRecorder } from "../stubs/recorder";
import { mockSensor } from "../stubs/sensor";
import { mockExtractFromTarget } from "../stubs/target";
import { mockTemplate } from "../stubs/template";
import { mockTodo } from "../stubs/todo";
import { mockWeather } from "../stubs/weather";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
/** The data of a provider, as set by its script child. */
interface EmbedProviderData {
entities?: EntityInput[];
areas?: DemoArea[];
calendars?: Record<string, CalendarEventApiData[]>;
energy?: Partial<EnergyPreferences>;
}
const SAMPLE_CALENDAR_EVENTS = [FAMILY_EVENTS, WORK_EVENTS];
// "living_room" becomes "Living room"
const areaName = (areaId: string) => {
const name = areaId.replaceAll("_", " ");
return name.charAt(0).toUpperCase() + name.slice(1);
};
/**
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
* inside it. Cards in the same provider share their entities.
*/
@customElement("ha-embed-provider")
export class HaEmbedProvider extends LitElement {
@provide({ context: embedHassContext })
@property({ attribute: false })
public hass!: MockHomeAssistant;
/**
* All entities of the backend. Each change replaces all entities.
* Also read from a JSON or YAML script child, see `readScriptData`. The
* script holds either the list of entities or an object with `entities`,
* `areas`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/** All areas. Without them, an area for each `area_id` of the entities. */
@property({ attribute: false }) public areas?: DemoArea[];
/**
* Events of calendar entities by entity ID. Calendars without events here
* get sample events around today.
*/
@property({ attribute: false }) public calendars?: Record<
string,
CalendarEventApiData[]
>;
/** Energy preferences. Without them, energy is not set up. */
@property({ attribute: false }) public energy?: Partial<EnergyPreferences>;
/** Light or dark mode of the cards. Without it, the browser setting. */
@property({ attribute: "color-scheme" }) public colorScheme?:
"light" | "dark";
private _reducedMotion = new ContextProvider(this, {
context: reducedMotionContext,
initialValue: false,
});
private _prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
private _unsubMediaQueries: (() => void)[] = [];
private _scriptRead = false;
private _applyColorScheme() {
const dark = this.colorScheme
? this.colorScheme === "dark"
: this._prefersDark;
if (this.hass.themes.darkMode !== dark) {
this.hass.updateHass({
themes: { ...this.hass.themes, darkMode: dark },
selectedTheme: { ...this.hass.selectedTheme!, dark },
});
}
}
constructor() {
super();
// Set up in the constructor so that the contexts exist before the
// child cards connect and request them.
const hass = provideHass(this);
hass.updateTranslations(null);
hass.updateTranslations("lovelace");
mockIcons(hass);
mockAuth(hass);
mockHistory(hass);
mockRecorder(hass);
mockTemplate(hass);
mockSensor(hass);
mockMediaPlayer(hass);
mockTodo(hass);
mockWeather(hass);
mockLogbook(hass);
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "logbook"],
},
});
mockCalendarEvents(hass, (entityId, currentHass) => {
if (!(entityId in currentHass.states)) {
return null;
}
const calendarIds = Object.keys(currentHass.states).filter((id) =>
id.startsWith("calendar.")
);
return (
this.calendars?.[entityId] ??
SAMPLE_CALENDAR_EVENTS[
calendarIds.indexOf(entityId) % SAMPLE_CALENDAR_EVENTS.length
]
);
});
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
Object.values(currentHass.areas)
);
// The registry of the entities, without settings such as calendar colors
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
(entity): EntityRegistryEntry => ({
...newEntry({
entity_id: entity.entity_id,
platform: entity.platform ?? "demo",
unique_id: entity.entity_id,
}),
area_id: entity.area_id ?? null,
device_id: entity.device_id ?? null,
})
);
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
registryEntries(currentHass)
);
hass.mockWS(
"config/entity_registry/get",
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => {
const entry = registryEntries(currentHass).find(
(item) => item.entity_id === msg.entity_id
);
if (!entry) {
throw new Error(`Entity not found: ${msg.entity_id}`);
}
return extEntry(entry);
}
);
mockExtractFromTarget(hass);
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
...EMPTY_PREFERENCES,
...this.energy,
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
solar_forecast_domains: [],
}));
this._applyColorScheme();
}
public connectedCallback() {
super.connectedCallback();
this._unsubMediaQueries = [
listenMediaQuery("(prefers-color-scheme: dark)", (matches) => {
this._prefersDark = matches;
this._applyColorScheme();
}),
listenMediaQuery("(prefers-reduced-motion: reduce)", (matches) =>
this._reducedMotion.setValue(matches)
),
];
// Only once, so that moving the provider keeps the current entities
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptData(this) as
EntityInput[] | EmbedProviderData | undefined;
if (Array.isArray(data)) {
this.entities = data;
} else if (data) {
this.entities = data.entities ?? [];
this.areas = data.areas;
this.calendars = data.calendars;
this.energy = data.energy;
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubMediaQueries.forEach((unsub) => unsub());
this._unsubMediaQueries = [];
}
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("colorScheme")) {
this._applyColorScheme();
}
if (changedProps.has("entities") || changedProps.has("areas")) {
const areas: DemoArea[] =
this.areas ??
[
...new Set(
this.entities
.map((entity) => entity.area_id)
.filter((areaId) => areaId !== undefined)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = toAreaRegistryEntry(area);
});
// Before the entities, so that their names can include the area
this.hass.updateHass({ areas: areasById });
// Entities that services changed keep their state when only the areas
// change
if (changedProps.has("entities")) {
this.hass.addEntities(this.entities, true);
} else {
this.hass.updateFormatFunctions();
}
}
}
protected updated(changedProps: PropertyValues<this>) {
const oldHass = changedProps.get("hass");
// The first update holds the initial entities. They are not changes.
if (!oldHass) {
return;
}
// Other updates, such as loaded translations, keep the same states object.
if (oldHass.states !== this.hass.states) {
fireEvent(this, "states-changed", { states: this.hass.states });
}
}
protected render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-provider": HaEmbedProvider;
}
interface HASSDomEvents {
"states-changed": { states: HassEntities };
}
}
+20
View File
@@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/naming-convention */
declare global {
var __HA_STATIC_PATH__: string | undefined;
}
declare let __webpack_public_path__: string;
/* eslint-enable @typescript-eslint/naming-convention */
// Load chunks and static files from the server that hosts this script, so
// that other sites can embed the cards. Only the modern production build keeps
// `import.meta.url` (see build-scripts/rspack.cjs). The legacy and development
// builds only run on the demo site, where the default paths are correct.
// `new URL(path, import.meta.url)` in one expression is an asset reference for
// the bundler, so the script URL goes into a variable first.
if (__BUILD__ === "modern" && !__DEV__) {
const scriptUrl = import.meta.url;
__webpack_public_path__ = new URL(".", scriptUrl).href;
globalThis.__HA_STATIC_PATH__ = new URL("../static/", scriptUrl).href;
}
export {};
+17
View File
@@ -0,0 +1,17 @@
import { load } from "js-yaml";
/**
* Parses the data in a `<script type="application/json">` or
* `<script type="text/yaml">` child of the element.
*/
export const readScriptData = (element: HTMLElement): unknown => {
const script = element.querySelector<HTMLScriptElement>(
':scope > script[type="application/json"], :scope > script[type="text/yaml"]'
);
if (!script) {
return undefined;
}
return script.type === "text/yaml"
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
+61
View File
@@ -0,0 +1,61 @@
import { unsafeCSS } from "lit";
import { hex2rgb } from "../../../src/common/color/convert-color";
import { darkColorVariables } from "../../../src/resources/theme/color";
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
import { themeStyles } from "../../../src/resources/theme/theme";
// The theme styles set variables on `html`, next to fonts and other page
// styles. Only the variables, media queries, and keyframes are copied, and
// `html` becomes `selector`. This keeps the theme inside the card, so that it
// does not change the page that embeds the card.
const scopeThemeStyles = (cssText: string, selector: string): string => {
// A style element in a separate document parses the CSS without applying it.
const doc = document.implementation.createHTMLDocument("");
const style = doc.createElement("style");
style.textContent = cssText;
doc.head.append(style);
const convert = (rules: CSSRuleList): string =>
Array.from(rules, (rule) => {
if (rule instanceof CSSStyleRule) {
if (rule.selectorText !== "html") {
return "";
}
const variables = Array.from(rule.style)
.filter((name) => name.startsWith("--"))
.map((name) => `${name}:${rule.style.getPropertyValue(name)};`);
return `${selector}{${variables.join("")}}`;
}
if (rule instanceof CSSMediaRule) {
return `@media ${rule.conditionText}{${convert(rule.cssRules)}}`;
}
if (rule instanceof CSSKeyframesRule) {
return rule.cssText;
}
return "";
}).join("");
return convert(style.sheet!.cssRules);
};
const darkTheme = {
...darkSemanticVariables,
...darkColorVariables,
};
// Like processTheme in apply_themes_on_element, each hex color also sets its
// `--rgb-` variable, else those keep their light theme values
const darkVariables = Object.entries(darkTheme).flatMap(([name, value]) => {
const variable = `--${name}:${value};`;
if (!value.startsWith("#") || `rgb-${name}` in darkTheme) {
return [variable];
}
return [variable, `--rgb-${name}:${hex2rgb(value).join(",")};`];
});
export const embedThemeStyles = unsafeCSS(
scopeThemeStyles(themeStyles, ".theme") +
`.theme.dark{${darkVariables.join("")}}`
);
+32
View File
@@ -0,0 +1,32 @@
// Browsers only start a worker from a script on the page's own origin, and
// the workers of the cards, such as the markdown worker, are served next to
// this script. A worker from another origin starts from a blob instead, which
// loads the real worker script. The demo serves its scripts with CORS.
const pageWorker = globalThis.Worker;
globalThis.Worker = class extends pageWorker {
constructor(scriptUrl: string | URL, options?: WorkerOptions) {
const url = new URL(scriptUrl, location.href);
if (url.origin === location.origin) {
super(scriptUrl, options);
return;
}
const source =
options?.type === "module"
? `import ${JSON.stringify(url.href)};`
: `importScripts(${JSON.stringify(url.href)});`;
const blobUrl = URL.createObjectURL(
new Blob([source], { type: "text/javascript" })
);
try {
super(blobUrl, options);
} finally {
URL.revokeObjectURL(blobUrl);
}
}
};
export {};
+5
View File
@@ -36,6 +36,7 @@ import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockLogbook } from "./stubs/logbook";
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
@@ -50,6 +51,7 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import { mockWeather } from "./stubs/weather";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
@@ -75,6 +77,7 @@ const CONFIG_PANEL_COMMANDS = [
"get_conditions_for_target",
"get_services_for_target",
"extract_from_target",
"explain_target",
"script/config",
"config/automation/config",
"config/script/config",
@@ -190,7 +193,9 @@ export class HaDemo extends HomeAssistantAppEl {
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockLogbook(hass);
mockMediaPlayer(hass);
mockWeather(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
+762
View File
@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html>
<head>
<title>Home Assistant card components</title>
<%= renderTemplate("../../../src/html/_header.html.template") %>
<meta name="referrer" content="same-origin" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<style>
:root {
color-scheme: light;
--page-background: #fafafa;
--page-text: #212121;
--page-code-background: #e5e5e5;
--page-divider: #dddddd;
}
@media (prefers-color-scheme: dark) {
:root:not([data-scheme="light"]) {
color-scheme: dark;
--page-background: #111111;
--page-text: #e1e1e1;
--page-code-background: #1c1c1c;
--page-divider: #333333;
}
}
:root[data-scheme="dark"] {
color-scheme: dark;
--page-background: #111111;
--page-text: #e1e1e1;
--page-code-background: #1c1c1c;
--page-divider: #333333;
}
html {
background-color: var(--page-background);
color: var(--page-text);
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
line-height: 1.5;
margin: 0;
padding: 0 16px 64px;
}
main {
max-width: 880px;
margin: 0 auto;
}
h1 {
margin: 32px 0 8px;
}
h2 {
margin: 48px 0 8px;
}
pre {
background: var(--page-code-background);
border-radius: 8px;
padding: 12px 16px;
overflow-x: auto;
font-size: 13px;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
text-align: start;
vertical-align: top;
padding: 8px;
border-bottom: 1px solid var(--page-divider);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
align-items: start;
margin: 16px 0;
}
.single {
max-width: 400px;
margin: 16px 0;
}
.log {
height: 160px;
overflow-y: auto;
margin: 0;
}
button {
font: inherit;
padding: 6px 12px;
margin-inline-end: 8px;
cursor: pointer;
}
</style>
</head>
<body>
<main>
<h1>Home Assistant card components</h1>
<p>
Embed working Home Assistant dashboard cards in any page. The cards run
against a mocked backend in the browser. No Home Assistant server is
necessary.
</p>
<p>There are two components:</p>
<ul>
<li>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and
its entities.
</li>
<li>
<code>&lt;ha-embed-card&gt;</code> renders one card. It uses the
backend of a provider.
</li>
</ul>
<p>
All cards in the same provider share their entities. When you change
an entity in one card, the other cards show the change.
</p>
<p>
Color scheme of this page and its cards:
<button data-scheme="">Auto</button>
<button data-scheme="light">Light</button>
<button data-scheme="dark">Dark</button>
</p>
<h2>Use on your site</h2>
<p>
Load the script from the demo site. The script loads all other files
from the demo site too.
</p>
<pre><code>&lt;script type="module" src="https://demo.home-assistant.io/embed.js"&gt;&lt;/script&gt;</code></pre>
<h2>One card</h2>
<p>
Put the entities in a script tag inside the provider. Put the card
configuration in a script tag inside the card. Both take JSON with
<code>type="application/json"</code> or YAML with
<code>type="text/yaml"</code>. The card configuration is the same as in
a dashboard. YAML needs its indentation. If your site minifies HTML,
make sure the minifier keeps the whitespace in script tags, or use
JSON.
</p>
<pre><code>&lt;ha-embed-provider&gt;
&lt;script type="text/yaml"&gt;
- entity_id: light.kitchen
state: "on"
attributes:
friendly_name: Kitchen
supported_color_modes: [brightness]
color_mode: brightness
brightness: 180
&lt;/script&gt;
&lt;ha-embed-card&gt;
&lt;script type="text/yaml"&gt;
type: tile
entity: light.kitchen
tap_action:
action: toggle
features:
- type: light-brightness
&lt;/script&gt;
&lt;/ha-embed-card&gt;
&lt;/ha-embed-provider&gt;</code></pre>
<ha-embed-provider>
<!-- htmlmin:ignore --><script type="text/yaml">
- entity_id: light.kitchen
state: "on"
attributes:
friendly_name: Kitchen
supported_color_modes: [brightness]
color_mode: brightness
brightness: 180
</script><!-- htmlmin:ignore -->
<div class="single">
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: light.kitchen
tap_action:
action: toggle
features:
- type: light-brightness
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
</ha-embed-provider>
<p>
The <code>entities</code> attribute of the provider and the
<code>config</code> attribute of the card also take JSON.
</p>
<h2>Cards with shared state</h2>
<p>
These three cards are in one provider. Turn on a light in one card. The
other cards update. The log below shows the events of the provider.
</p>
<ha-embed-provider id="shared">
<script type="application/json">
[
{
"entity_id": "light.living_room",
"state": "off",
"attributes": {
"friendly_name": "Living room",
"supported_color_modes": ["brightness"],
"color_mode": "brightness",
"brightness": 255
}
},
{
"entity_id": "switch.coffee_maker",
"state": "on",
"attributes": {
"friendly_name": "Coffee maker",
"device_class": "outlet"
}
},
{
"entity_id": "climate.hallway",
"state": "heat",
"attributes": {
"friendly_name": "Hallway",
"current_temperature": 19.5,
"temperature": 21,
"min_temp": 7,
"max_temp": 30,
"target_temp_step": 0.5,
"hvac_modes": ["heat", "off"],
"hvac_action": "heating",
"supported_features": 1
}
},
{
"entity_id": "sensor.outside_temperature",
"state": "12.3",
"attributes": {
"friendly_name": "Outside temperature",
"device_class": "temperature",
"state_class": "measurement",
"unit_of_measurement": "°C"
}
}
]
</script>
<div class="cards">
<ha-embed-card>
<script type="application/json">
{
"type": "tile",
"entity": "light.living_room",
"tap_action": { "action": "toggle" }
}
</script>
</ha-embed-card>
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: entities
title: Living room
entities:
- light.living_room
- switch.coffee_maker
- sensor.outside_temperature
</script><!-- htmlmin:ignore -->
</ha-embed-card>
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
</ha-embed-provider>
<pre class="log" id="log"><code>Use the cards to see events here.</code></pre>
<h2>Cards outside a provider</h2>
<p>
A card does not have to be inside a provider. A card outside a provider
uses the first provider on the page. Put one provider with all entities
at the top of the page, and put the cards anywhere below it. If the
page has no provider, the card adds an empty provider to the page.
</p>
<p>
To use a different provider, set the <code>provider</code> attribute of
the card to the ID of the provider.
</p>
<pre><code>&lt;ha-embed-provider id="living-room"&gt;…&lt;/ha-embed-provider&gt;
&lt;p&gt;Some text about the coffee maker.&lt;/p&gt;
&lt;ha-embed-card provider="living-room"&gt;
&lt;script type="text/yaml"&gt;
type: tile
entity: switch.coffee_maker
&lt;/script&gt;
&lt;/ha-embed-card&gt;</code></pre>
<p>
These two cards are not inside a provider. The first card uses the
first provider on this page, the kitchen light at the top. The second
card uses the provider with the ID <code>shared</code> above.
</p>
<div class="cards">
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: light.kitchen
tap_action:
action: toggle
</script><!-- htmlmin:ignore -->
</ha-embed-card>
<ha-embed-card provider="shared">
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
<h2>Light and dark mode</h2>
<p>
The cards follow the light or dark mode of the browser. To choose the
mode, set the <code>color-scheme</code> attribute of the provider to
<code>light</code> or <code>dark</code>. Use this when your site has its
own switch for dark mode. The buttons at the top of this page do this.
The cards keep their styles inside. They do not change the styles of
your page.
</p>
<pre><code>&lt;ha-embed-provider color-scheme="dark"&gt;…&lt;/ha-embed-provider&gt;</code></pre>
<h2>Change entities from the page</h2>
<p>
Set the <code>entities</code> property to change entities from
JavaScript. The new list replaces all entities. To change one entity,
start from the current states.
</p>
<p>
<button id="set-temperature">Set a random outside temperature</button>
<button id="toggle-coffee">Toggle the coffee maker</button>
</p>
<pre><code>const provider = document.querySelector("ha-embed-provider");
provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
entity.entity_id === "sensor.outside_temperature"
? { ...entity, state: "18.1" }
: entity
);</code></pre>
<p>
To use the actions of an entity, call a service. The provider updates
the state and the cards.
</p>
<pre><code>provider.hass.callService("switch", "toggle", {
entity_id: "switch.coffee_maker",
});</code></pre>
<h2>Listen to changes</h2>
<p>
The provider fires a <code>states-changed</code> event when the state
of one or more entities changes. The event has the states of all
entities. The event bubbles.
</p>
<pre><code>provider.addEventListener("states-changed", (ev) =&gt; {
const { states } = ev.detail;
console.log(states["light.living_room"].state);
});</code></pre>
<p>
Read the current state of all entities with
<code>provider.hass.states</code>.
</p>
<h2>Areas, calendars and energy</h2>
<p>
Some cards need data that is not an entity. To set it, put an object in
the script tag of the provider instead of the list. The object has the
list in <code>entities</code>, and any of these keys:
</p>
<ul>
<li>
<code>areas</code>: a list of areas, each with an
<code>area_id</code> and a <code>name</code>. Without it, each
<code>area_id</code> of the entities becomes an area.
</li>
<li>
<code>calendars</code>: the events of calendar entities, by entity
ID. Each event has a <code>summary</code>, a <code>start</code> and
an <code>end</code>. Calendars without events here get sample events
around today.
</li>
<li>
<code>energy</code>: the energy settings, the same as the
<code>energy/get_prefs</code> response. Without it, energy is not
set up.
</li>
</ul>
<p>
Weather entities get a forecast made up from their condition and
temperature, or their <code>forecast</code> attribute when they have
one. Set <code>supported_features</code> to show forecasts.
</p>
<pre><code>&lt;ha-embed-provider&gt;
&lt;script type="text/yaml"&gt;
entities:
- entity_id: calendar.family
state: "off"
attributes:
friendly_name: Family
areas:
- area_id: kitchen
name: Kitchen
calendars:
calendar.family:
- summary: Dentist
start: "2026-11-02T15:30:00+00:00"
end: "2026-11-02T16:15:00+00:00"
&lt;/script&gt;
…
&lt;/ha-embed-provider&gt;</code></pre>
<p>This calendar card uses the sample events.</p>
<ha-embed-provider>
<script type="application/json">
{
"entities": [
{
"entity_id": "calendar.family",
"state": "off",
"attributes": { "friendly_name": "Family" }
}
]
}
</script>
<div class="single">
<ha-embed-card
config='{ "type": "calendar", "entities": ["calendar.family"], "initial_view": "listWeek" }'
></ha-embed-card>
</div>
</ha-embed-provider>
<h2>Custom cards</h2>
<p>
Custom cards work too. Load the script of the custom card on the page,
and use <code>custom:</code> and the element name as the card type.
The custom card gets the same mocked backend as the other cards.
<code>window.loadCardHelpers()</code> is available to create built-in
cards.
</p>
<p>
The mocked backend only knows a part of the Home Assistant API. History
and statistics are made up from the current state of each entity.
Templates are not rendered, they come back as they are. The logbook is
empty. Other requests get an error. From another site, the script of
the custom card needs CORS headers, like the embed script.
</p>
<pre><code>&lt;script type="module" src="https://example.com/embed-demo-custom-card.js"&gt;&lt;/script&gt;
&lt;ha-embed-card&gt;
&lt;script type="text/yaml"&gt;
type: custom:embed-demo-custom-card
entity: light.office
&lt;/script&gt;
&lt;/ha-embed-card&gt;</code></pre>
<ha-embed-provider>
<script type="application/json">
[
{
"entity_id": "light.office",
"state": "on",
"attributes": {
"friendly_name": "Office",
"supported_color_modes": ["brightness"],
"color_mode": "brightness",
"brightness": 120
}
}
]
</script>
<div class="single">
<ha-embed-card
config='{ "type": "custom:embed-demo-custom-card", "entity": "light.office" }'
></ha-embed-card>
</div>
</ha-embed-provider>
<p>This is the code of the custom card:</p>
<pre><code>class EmbedDemoCustomCard extends HTMLElement {
setConfig(config) {
if (!config.entity) {
throw new Error("Set an entity");
}
this._config = config;
}
set hass(hass) {
this._hass = hass;
this._render();
}
getCardSize() {
return 3;
}
async _render() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).innerHTML = `
&lt;style&gt;
ha-card { padding: 16px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background: var(--accent-color);
color: var(--text-accent-color, #fff);
font-size: 12px;
}
.tile { margin: 12px 0; }
&lt;/style&gt;
&lt;ha-card&gt;
&lt;span class="badge"&gt;Custom card&lt;/span&gt;
&lt;p&gt;
This is a custom card. It is not part of Home Assistant. The page
defines it, and it creates the tile card below with the card
helpers.
&lt;/p&gt;
&lt;div class="tile"&gt;&lt;/div&gt;
&lt;button&gt;Toggle from the custom card&lt;/button&gt;
&lt;/ha-card&gt;`;
this.shadowRoot.querySelector("button").addEventListener("click", () =&gt;
this._hass.callService("homeassistant", "toggle", {
entity_id: this._config.entity,
})
);
}
// Keep the promise, so that a second update does not create a second tile.
if (!this._tile) {
this._tile = window.loadCardHelpers().then((helpers) => {
const tile = helpers.createCardElement({
type: "tile",
entity: this._config.entity,
});
this.shadowRoot.querySelector(".tile").append(tile);
return tile;
});
}
(await this._tile).hass = this._hass;
}
}
customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pre>
<script type="module">
class EmbedDemoCustomCard extends HTMLElement {
setConfig(config) {
if (!config.entity) {
throw new Error("Set an entity");
}
this._config = config;
}
set hass(hass) {
this._hass = hass;
this._render();
}
getCardSize() {
return 3;
}
async _render() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).innerHTML = `
<style>
ha-card { padding: 16px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background: var(--accent-color);
color: var(--text-accent-color, #fff);
font-size: 12px;
}
.tile { margin: 12px 0; }
</style>
<ha-card>
<span class="badge">Custom card</span>
<p>
This is a custom card. It is not part of Home Assistant. The page
defines it, and it creates the tile card below with the card
helpers.
</p>
<div class="tile"></div>
<button>Toggle from the custom card</button>
</ha-card>`;
this.shadowRoot.querySelector("button").addEventListener("click", () =>
this._hass.callService("homeassistant", "toggle", {
entity_id: this._config.entity,
})
);
}
// Keep the promise, so that a second update does not create a second tile.
if (!this._tile) {
this._tile = window.loadCardHelpers().then((helpers) => {
const tile = helpers.createCardElement({
type: "tile",
entity: this._config.entity,
});
this.shadowRoot.querySelector(".tile").append(tile);
return tile;
});
}
(await this._tile).hass = this._hass;
}
}
customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);
</script>
<h2>Reference</h2>
<h3><code>&lt;ha-embed-provider&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>entities</code></td>
<td>Attribute and property</td>
<td>
A list of entities. Each entity has an <code>entity_id</code>, a
<code>state</code>, and <code>attributes</code>. The optional keys
<code>area_id</code>, <code>device_id</code>, and
<code>platform</code> set the entity registry entry. A new list
replaces all entities. A JSON or YAML script child sets the first
list.
</td>
</tr>
<tr>
<td><code>color-scheme</code></td>
<td>Attribute and property (<code>colorScheme</code>)</td>
<td>
<code>light</code> or <code>dark</code>. Without it, the cards
follow the browser setting.
</td>
</tr>
<tr>
<td><code>hass</code></td>
<td>Property, read-only</td>
<td>The mocked Home Assistant object that the cards use.</td>
</tr>
<tr>
<td><code>states-changed</code></td>
<td>Event</td>
<td>
Fires when the state of one or more entities changes. The detail
has <code>states</code>, the states of all entities by entity ID.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-card&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The card configuration, the same as in a dashboard. A JSON or YAML
script child sets the first configuration.
</td>
</tr>
<tr>
<td><code>provider</code></td>
<td>Attribute and property</td>
<td>
The ID of the provider to use. Without it, the card uses the
provider around it, else the first provider on the page.
</td>
</tr>
</table>
<p>
The mocked backend knows the actions of lights, switches, climate,
covers, fans, locks, media players, and more. Actions that open dialogs,
such as <code>more-info</code>, do nothing.
</p>
</main>
<script>
(function () {
var shared = document.getElementById("shared");
var log = document.getElementById("log");
var first = true;
shared.addEventListener("states-changed", function (ev) {
var states = ev.detail.states;
var line =
new Date().toLocaleTimeString() +
" " +
Object.keys(states)
.map(function (entityId) {
return entityId + "=" + states[entityId].state;
})
.join(", ");
if (first) {
log.textContent = "";
first = false;
}
log.textContent = line + "\n" + log.textContent;
});
document
.getElementById("set-temperature")
.addEventListener("click", function () {
var states = shared.hass.states;
shared.entities = Object.keys(states).map(function (entityId) {
var entity = states[entityId];
return {
entity_id: entityId,
state:
entityId === "sensor.outside_temperature"
? (Math.random() * 30 - 5).toFixed(1)
: entity.state,
attributes: entity.attributes,
};
});
});
document
.querySelectorAll("button[data-scheme]")
.forEach(function (button) {
button.addEventListener("click", function () {
var scheme = button.getAttribute("data-scheme");
if (scheme) {
document.documentElement.setAttribute("data-scheme", scheme);
} else {
document.documentElement.removeAttribute("data-scheme");
}
document
.querySelectorAll("ha-embed-provider")
.forEach(function (provider) {
if (scheme) {
provider.setAttribute("color-scheme", scheme);
} else {
provider.removeAttribute("color-scheme");
}
});
});
});
document
.getElementById("toggle-coffee")
.addEventListener("click", function () {
shared.hass.callService("switch", "toggle", {
entity_id: "switch.coffee_maker",
});
});
})();
</script>
<%= renderTemplate("../../../src/html/_js_base.html.template") %>
<%= renderTemplate("../../../src/html/_script_loader.html.template") %>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
import "<%= latestEntryJS[0] %>";
+15 -13
View File
@@ -12,6 +12,20 @@ export interface DemoArea {
let areas: AreaRegistryEntry[] = [];
export const toAreaRegistryEntry = (area: DemoArea): AreaRegistryEntry => ({
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,
});
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: DemoArea[] = []
@@ -25,19 +39,7 @@ 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,
}));
areas = demoAreas.map(toAreaRegistryEntry);
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
+10 -120
View File
@@ -2,14 +2,14 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import type { ConditionDescriptions } from "../../../src/data/condition";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { TriggerDescriptions } from "../../../src/data/trigger";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
import {
conditionDescriptions,
triggerDescriptions,
} from "./automation_platforms_data";
import { explainTarget } from "./explain_target";
import { expandTarget, mockExtractFromTarget } from "./target";
type Descriptions = TriggerDescriptions | ConditionDescriptions;
@@ -20,109 +20,6 @@ interface EntityFilter {
supported_features?: number[];
}
// Expands a target to its areas, devices and entities, like core does
const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
const matchesFilter = (
hass: MockHomeAssistant,
entityId: string,
@@ -228,27 +125,20 @@ export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
);
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
mockExtractFromTarget(hass);
hass.mockWS(
"extract_from_target",
"explain_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
) =>
explainTarget(
msg.target,
(target, primaryEntitiesOnly) =>
expandTarget(currentHass, target, primaryEntitiesOnly),
Object.keys(currentHass.states),
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
)
);
hass.mockWS(
"get_services_for_target",
+122
View File
@@ -0,0 +1,122 @@
import type { CalendarEventApiData } from "../../../src/data/calendar";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample events relative to today, so demos always show something in the
// current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
export const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
export const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const toTime = (value: CalendarEventApiData["start"]) =>
new Date(
typeof value === "string"
? value
: "dateTime" in value
? value.dateTime
: value.date
).getTime();
const inRange = (
events: CalendarEventApiData[],
start: string | null,
end: string | null
): CalendarEventApiData[] => {
const startTime = start ? new Date(start).getTime() : -Infinity;
const endTime = end ? new Date(end).getTime() : Infinity;
return events.filter(
(event) => toTime(event.end) > startTime && toTime(event.start) < endTime
);
};
/**
* Answers event requests of calendar entities with the events of
* `getEvents`. A calendar without events gets `null`, like a calendar whose
* integration fails to return them.
*/
export const mockCalendarEvents = (
hass: MockHomeAssistant,
getEvents: (
entityId: string,
currentHass: MockHomeAssistant
) => CalendarEventApiData[] | null | undefined
) => {
hass.mockWS("calendar/event/subscribe", (msg, currentHass, onChange) => {
const events = getEvents(msg.entity_id, currentHass);
onChange?.({ events: events ? inRange(events, msg.start, msg.end) : null });
return () => undefined;
});
// Home Assistant before 2026.5 has no event subscription, so cards that
// support it ask the REST API instead.
hass.mockAPI(/^calendars\/[^/?]+\?/, (currentHass, _method, path) => {
const url = new URL(path, location.origin);
const entityId = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
const events = getEvents(entityId, currentHass);
if (!events) {
return Promise.reject(new Error("Calendar not found"));
}
return inRange(
events,
url.searchParams.get("start"),
url.searchParams.get("end")
);
});
};
@@ -59,6 +59,10 @@ const REGISTRY_ENTRIES = [
export const infraredFixtures: ConnectivityFixtures = {
components: ["infrared"],
manifests: [
manifest("infrared", "Infrared", {
config_flow: false,
integration_type: "entity",
}),
manifest("broadlink", "Broadlink", {
integration_type: "hub",
iot_class: "local_polling",
@@ -42,7 +42,13 @@ const REGISTRY_ENTRIES = [
export const radioFrequencyFixtures: ConnectivityFixtures = {
components: ["radio_frequency"],
commands: ["radio_frequency/"],
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
manifests: [
manifest("radio_frequency", "Radio frequency", {
config_flow: false,
integration_type: "entity",
}),
manifest("esphome", "ESPHome", { integration_type: "device" }),
],
configEntries: [
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
],
+11 -9
View File
@@ -15,6 +15,7 @@ import type {
ZHANetworkBackup,
ZHANetworkSettings,
} from "../../../../../src/data/zha";
import { ZHADeviceType } from "../../../../../src/data/zha";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
@@ -56,7 +57,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-coordinator",
user_given_name: "Home Assistant Connect ZBT-1",
power_source: "Mains",
device_type: "Coordinator",
device_type: ZHADeviceType.Coordinator,
active_coordinator: true,
signature: {},
neighbors: [
@@ -83,7 +84,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-porch-light",
user_given_name: "Porch light",
power_source: "Mains",
device_type: "Router",
device_type: ZHADeviceType.Router,
active_coordinator: false,
signature: {},
neighbors: [
@@ -110,7 +111,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-hall-motion",
user_given_name: "Hall motion",
power_source: "Battery",
device_type: "EndDevice",
device_type: ZHADeviceType.EndDevice,
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
@@ -133,7 +134,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-tv-plug",
user_given_name: "TV plug",
power_source: "Mains",
device_type: "Router",
device_type: ZHADeviceType.Router,
active_coordinator: false,
signature: {},
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
@@ -156,7 +157,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-kitchen-switch",
user_given_name: "Kitchen switch",
power_source: "Battery",
device_type: "EndDevice",
device_type: ZHADeviceType.EndDevice,
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
@@ -179,7 +180,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-landing-sensor",
user_given_name: "Landing sensor",
power_source: "Battery",
device_type: "EndDevice",
device_type: ZHADeviceType.EndDevice,
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
@@ -202,7 +203,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-garage-contact",
user_given_name: "Garage contact",
power_source: "Battery",
device_type: "EndDevice",
device_type: ZHADeviceType.EndDevice,
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
@@ -225,7 +226,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-office-plug",
user_given_name: "Office plug",
power_source: "Mains",
device_type: "Router",
device_type: ZHADeviceType.Router,
active_coordinator: false,
signature: {},
neighbors: [
@@ -593,7 +594,8 @@ export const mockZha = (hass: MockHomeAssistant) => {
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
DEVICES.filter(
(candidate) =>
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
candidate.device_type === ZHADeviceType.Router &&
candidate.ieee !== msg.ieee
)
);
hass.mockWS("zha/devices/bind", () => undefined);
+20 -20
View File
@@ -11,7 +11,7 @@ export const energyEntities = () =>
entity_id: "sensor.solar_production",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Solar",
unit_of_measurement: "kWh",
},
@@ -20,7 +20,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_input",
state: "4",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Battery Input",
unit_of_measurement: "kWh",
},
@@ -29,7 +29,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_output",
state: "3",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Battery Output",
unit_of_measurement: "kWh",
},
@@ -38,7 +38,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Grid consumption low tariff",
unit_of_measurement: "kWh",
},
@@ -47,7 +47,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Grid consumption high tariff",
unit_of_measurement: "kWh",
},
@@ -56,7 +56,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Returned to grid low tariff",
unit_of_measurement: "kWh",
},
@@ -65,7 +65,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Returned to grid high tariff",
unit_of_measurement: "kWh",
},
@@ -74,7 +74,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1_cost",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -82,7 +82,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2_cost",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -90,7 +90,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1_compensation",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -98,7 +98,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2_compensation",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -138,7 +138,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas_cost",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -146,7 +146,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas",
state: "4",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Gas",
unit_of_measurement: "m³",
},
@@ -155,7 +155,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_water",
state: "4000",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Water",
unit_of_measurement: "L",
},
@@ -164,7 +164,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_car",
state: "4",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Electric car",
unit_of_measurement: "kWh",
},
@@ -173,7 +173,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_ac",
state: "3",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Air conditioning",
unit_of_measurement: "kWh",
},
@@ -182,7 +182,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_washing_machine",
state: "6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Washing machine",
unit_of_measurement: "kWh",
},
@@ -191,7 +191,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_dryer",
state: "5.5",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Dryer",
unit_of_measurement: "kWh",
},
@@ -200,7 +200,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_heat_pump",
state: "6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Heat pump",
unit_of_measurement: "kWh",
},
@@ -209,7 +209,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_boiler",
state: "7",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Boiler",
unit_of_measurement: "kWh",
},
+14 -2
View File
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
const newEntry = (
export const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
@@ -43,7 +43,9 @@ const displayFields = (entry: EntityRegistryEntry) => ({
hidden: entry.hidden_by !== null,
});
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
export const extEntry = (
entry: EntityRegistryEntry
): ExtEntityRegistryEntry => ({
...entry,
capabilities: {},
aliases: [],
@@ -56,6 +58,16 @@ export const mockEntityRegistry = (
entries = [...data];
// A new array, so subscribers see the registry changed after an update
hass.mockWS("config/entity_registry/list", () => [...entries]);
hass.mockWS(
"config/entity_registry/get",
(msg: { entity_id: string }): ExtEntityRegistryEntry => {
const entry = entries.find((e) => e.entity_id === msg.entity_id);
if (!entry) {
throw new Error(`Entity not found: ${msg.entity_id}`);
}
return extEntry(entry);
}
);
hass.mockWS(
"config/entity_registry/get_entries",
(msg: { entity_ids: string[] }) => {
+79
View File
@@ -0,0 +1,79 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import type {
TargetExplanation,
HassServiceTargetWithExclude,
TargetItem,
TargetType,
} from "../../../src/data/target";
const TYPES: TargetType[] = ["entity", "device", "area", "floor", "label"];
const items = (target?: HassServiceTarget): TargetItem[] =>
TYPES.flatMap((type) => {
const ids = target?.[`${type}_id`];
return ids ? ensureArray(ids).map((id) => ({ type, id: String(id) })) : [];
});
/** What one target item resolves to, and the areas and devices it reaches. */
export interface ResolvedTargetItem {
areas: string[];
devices: string[];
entities: string[];
}
/**
* Like core's explain_target: resolves each item of the target and of its
* `exclude` on its own with `resolve`, and tells what each exclude takes out
* of each included item. Like core, `entity_id: all` is every entity in
* `entityIds`, and excludes resolve every entity, not only the primary ones.
*/
export const explainTarget = (
target: HassServiceTargetWithExclude,
resolve: (
target: HassServiceTarget,
primaryEntitiesOnly: boolean
) => ResolvedTargetItem,
entityIds: string[],
primaryEntitiesOnly = true
): TargetExplanation => {
const single = ({ type, id }: TargetItem): HassServiceTarget => ({
[`${type}_id`]: [id],
});
const includes = items(target).map((item) => {
if (item.type === "entity" && item.id === "all") {
return {
...item,
entities: entityIds,
referenced_areas: [],
referenced_devices: [],
};
}
const { areas, devices, entities } = resolve(
single(item),
primaryEntitiesOnly
);
return {
...item,
entities,
referenced_areas: areas,
referenced_devices: devices,
};
});
return {
includes,
excludes: items(target.exclude).map((item) => {
const excluded = new Set(resolve(single(item), false).entities);
return {
...item,
removed: includes
.map(({ type, id, entities }) => ({
type,
id,
entities: entities.filter((entityId) => excluded.has(entityId)),
}))
.filter((include) => include.entities.length > 0),
};
}),
};
};
+22 -2
View File
@@ -31,7 +31,7 @@ const exposedEntities: Record<string, ExposeEntitySettings> = {
export const mockExpose = (hass: MockHomeAssistant) => {
hass.mockWS("homeassistant/expose_entity/list", () => ({
exposed_entities: exposedEntities,
exposed_entities: structuredClone(exposedEntities),
}));
hass.mockWS(
"homeassistant/expose_new_entities/get",
@@ -39,6 +39,26 @@ export const mockExpose = (hass: MockHomeAssistant) => {
expose_new: msg.assistant !== "cloud.google_assistant",
})
);
hass.mockWS("homeassistant/expose_entity", () => null);
hass.mockWS(
"homeassistant/expose_entity",
(msg: {
assistants: (keyof ExposeEntitySettings)[];
entity_ids: string[];
should_expose: boolean;
}) => {
const changedEntityIds = msg.entity_ids.filter((entityId) => {
const settings = (exposedEntities[entityId] ??= {});
let changed = false;
for (const assistant of msg.assistants) {
if (Boolean(settings[assistant]) !== msg.should_expose) {
changed = true;
}
settings[assistant] = msg.should_expose;
}
return changed;
});
return { changed_entity_ids: changedEntityIds };
}
);
hass.mockWS("homeassistant/expose_new_entities/set", () => null);
};
+15
View File
@@ -0,0 +1,15 @@
import type { LogbookStreamMessage } from "../../../src/data/logbook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/**
* Answers logbook subscriptions with an empty logbook. No entries come from
* an automation or script trace.
*/
export const mockLogbook = (hass: MockHomeAssistant) => {
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
onChange?.({ events: [] } satisfies LogbookStreamMessage);
return () => undefined;
});
hass.mockWS("trace/contexts", () => ({}));
};
+1
View File
@@ -149,6 +149,7 @@ const repositories: RepositoryBase[] = [
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
...repository,
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
beta: false,
default_branch: "main",
issues: 12,
releases: [repository.available_version],
+3 -1
View File
@@ -372,7 +372,9 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
statistics[id] =
entityState && "last_reset" in entityState.attributes
entityState &&
(entityState.attributes.state_class === "total" ||
entityState.attributes.state_class === "total_increasing")
? generateSumStatistics(
start,
end,
+132
View File
@@ -0,0 +1,132 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
// Expands a target to its areas, devices and entities, like core does
export const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
export const mockExtractFromTarget = (hass: MockHomeAssistant) =>
hass.mockWS(
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
+133
View File
@@ -0,0 +1,133 @@
import type {
ForecastAttribute,
ModernForecastType,
} from "../../../src/data/weather";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const HOUR = 3600000;
const CONDITIONS = [
"partlycloudy",
"rainy",
"cloudy",
"sunny",
"pouring",
"partlycloudy",
"sunny",
];
// Daily changes around the current temperature, so the forecast looks real.
const TEMPERATURE_CHANGES = [0, -1.5, -3, -1, 1.5, 2.5, 1];
const PRECIPITATION = new Map([
["rainy", 2.4],
["pouring", 9.1],
]);
const forecastEntry = (
datetime: Date,
temperature: number,
condition: string
): ForecastAttribute => ({
datetime: datetime.toISOString(),
condition,
temperature: Math.round(temperature * 10) / 10,
precipitation: PRECIPITATION.get(condition) ?? 0,
precipitation_probability: PRECIPITATION.has(condition) ? 80 : 10,
});
const generateForecast = (
forecastType: ModernForecastType,
temperature: number,
condition: string
): ForecastAttribute[] => {
const conditions = [condition, ...CONDITIONS.slice(1)];
const now = new Date();
if (forecastType === "hourly") {
now.setMinutes(0, 0, 0);
return Array.from({ length: 24 }, (_, hour) => {
const datetime = new Date(now.getTime() + (hour + 1) * HOUR);
// Warmest mid-afternoon and coldest before dawn
const change =
3 * Math.sin(((datetime.getHours() - 9) / 24) * 2 * Math.PI) -
3 * Math.sin(((now.getHours() - 9) / 24) * 2 * Math.PI);
return forecastEntry(
datetime,
temperature + change,
conditions[Math.floor(hour / 6)]
);
});
}
now.setHours(12, 0, 0, 0);
if (forecastType === "twice_daily") {
return conditions.flatMap((dayCondition, day) => {
const datetime = new Date(now.getTime() + day * 24 * HOUR);
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return [
{
...forecastEntry(datetime, dayTemperature, dayCondition),
is_daytime: true,
},
{
...forecastEntry(
new Date(datetime.getTime() + 12 * HOUR),
dayTemperature - 6,
dayCondition
),
is_daytime: false,
},
];
});
}
return conditions.map((dayCondition, day) => {
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return {
...forecastEntry(
new Date(now.getTime() + day * 24 * HOUR),
dayTemperature,
dayCondition
),
templow: Math.round((dayTemperature - 6) * 10) / 10,
};
});
};
/**
* Answers forecast subscriptions of weather entities. An entity with a
* `forecast` attribute gets that forecast. Other entities get a forecast made
* up from their condition and temperature.
*/
export const mockWeather = (hass: MockHomeAssistant) => {
hass.mockWS(
"weather/subscribe_forecast",
(
msg: { entity_id: string; forecast_type: ModernForecastType },
currentHass,
onChange
) => {
const stateObj = currentHass.states[msg.entity_id];
onChange?.({
type: msg.forecast_type,
forecast: stateObj
? (stateObj.attributes.forecast ??
generateForecast(
msg.forecast_type,
Number(stateObj.attributes.temperature ?? 15),
stateObj.state
))
: null,
});
return () => undefined;
}
);
};
+10 -77
View File
@@ -1,68 +1,14 @@
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../../../demo/src/stubs/calendar";
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { CalendarEntityFeature } from "../../../src/data/calendar";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample calendars with events relative to today, so demos always show
// something in the current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
"calendar.family": FAMILY_EVENTS,
"calendar.work": WORK_EVENTS,
@@ -79,8 +25,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Family",
message: "School run",
all_day: false,
start_time: at(0, 8),
end_time: at(0, 8, 30),
start_time: FAMILY_EVENTS[0].start,
end_time: FAMILY_EVENTS[0].end,
location: "",
description: "",
supported_features:
@@ -96,8 +42,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Work",
message: "Stand-up",
all_day: false,
start_time: at(0, 9),
end_time: at(0, 9, 15),
start_time: WORK_EVENTS[0].start,
end_time: WORK_EVENTS[0].end,
},
},
{
@@ -145,20 +91,7 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
registryEntry("calendar.empty"),
registryEntry("calendar.holidays"),
]);
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
const start = new Date(msg.start).getTime();
const end = new Date(msg.end).getTime();
const events = EVENTS[msg.entity_id];
onChange?.({
events:
events?.filter(
(event) =>
new Date(event.end as string).getTime() > start &&
new Date(event.start as string).getTime() < end
) ?? null,
});
return () => undefined;
});
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
// Changes made in the event dialogs are accepted but not stored
hass.mockWS("calendar/event/create", () => null);
hass.mockWS("calendar/event/update", () => null);
+30
View File
@@ -48,6 +48,36 @@ export const createMediaPlayerEntities = () => [
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.music_all_features",
state: "playing",
attributes: {
friendly_name: "Playing The Music (all features)",
media_content_type: "music",
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
// Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + On + Off +
// Play Media + Volume Step + Select Source + Stop + Clear + Play + Shuffle Set +
// Sound Mode + Browse Media + Repeat Set + Grouping
supported_features: 1048511,
entity_picture: "/images/album_cover.jpg",
media_duration: 300,
media_position: 0,
media_position_updated_at: new Date(
// 23 seconds in
new Date().getTime() - 23000
).toISOString(),
volume_level: 0.5,
is_volume_muted: false,
shuffle: false,
repeat: "off",
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
source: "AirPlay",
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
sound_mode: "Music",
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.stream_playing",
state: "playing",
@@ -1,25 +1,21 @@
import { mdiFan, mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3 } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement } from "lit/decorators";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-card";
import type { HaDropdownSelectEvent } from "../../../../src/components/ha-dropdown";
import "../../../../src/components/ha-control-select-menu";
import "../../../../src/components/ha-list-item";
import type { SelectOption } from "../../../../src/components/ha-control-select-menu";
import "../../../../src/components/ha-svg-icon";
interface SelectMenuOptions {
label: string;
value: string;
icon?: string;
}
interface SelectMenu {
label: string;
icon: string;
class?: string;
disabled?: boolean;
options: SelectMenuOptions[];
options: SelectOption[];
}
const selects: SelectMenu[] = [
@@ -48,17 +44,17 @@ const selects: SelectMenu[] = [
{
value: "low",
label: "Low",
icon: mdiFanSpeed1,
iconPath: mdiFanSpeed1,
},
{
value: "medium",
label: "Medium",
icon: mdiFanSpeed2,
iconPath: mdiFanSpeed2,
},
{
value: "high",
label: "High",
icon: mdiFanSpeed3,
iconPath: mdiFanSpeed3,
},
],
},
@@ -72,6 +68,8 @@ const selects: SelectMenu[] = [
@customElement("demo-components-ha-control-select-menu")
export class DemoHaControlSelectMenu extends LitElement {
@state() private _values: Record<string, string> = {};
protected render(): TemplateResult {
return html`
<ha-card>
@@ -81,31 +79,13 @@ export class DemoHaControlSelectMenu extends LitElement {
<div class="card-content">
<ha-control-select-menu
.label=${select.label}
.value=${this._values[select.label]}
.options=${select.options}
?disabled=${select.disabled}
fixedMenuPosition
naturalMenuWidth
data-label=${select.label}
@wa-select=${this._handleSelect}
>
<ha-svg-icon slot="icon" .path=${select.icon}></ha-svg-icon>
${select.options.map(
(option) => html`
<ha-list-item
.value=${option.value}
.graphic=${option.icon ? "icon" : undefined}
>
${
option.icon
? html`
<ha-svg-icon
slot="graphic"
.path=${option.icon}
></ha-svg-icon>
`
: nothing
}
${option.label ?? option.value}
</ha-list-item>
`
)}
</ha-control-select-menu>
</div>
`
@@ -114,6 +94,17 @@ export class DemoHaControlSelectMenu extends LitElement {
`;
}
private _handleSelect(
ev: HaDropdownSelectEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
const label = ev.currentTarget.dataset.label;
const value = ev.detail.item.value;
if (!label || !value) {
return;
}
this._values = { ...this._values, [label]: value };
}
static styles = css`
ha-card {
max-width: 600px;
@@ -50,6 +50,18 @@ With no cursor yet, <kbd>↓</kbd> and <kbd>Enter</kbd> start from the `value`
row, or from the top match while searching. With one item in the list,
<kbd>Enter</kbd> picks it whether or not the cursor has moved.
## Screen readers
DOM focus stays in the search field, so screen readers do not hear the list
change. While searching, a visually hidden `role="status"` message says how
many items match the search and names it, so a new search with the same count
is still read out. It is cleared as soon as the list changes and filled in once
typing pauses for half a second, so it doesn't talk over each typed character
or describe the previous search. When nothing matches, it reads a not
found label that takes the search, or says there are no results for it. Section
titles, placeholder rows, "Add custom item", the additional items and rows that
`isSearchResult` rejects are not counted.
## Implementation
Without `sections`, the component filters the items from `getItems` itself as the
@@ -79,6 +91,7 @@ means supplying one.
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| isSearchResult | Function | - | Whether a row from `getItems` counts as a match for screen readers. The quick bar leaves out its "Ask Assist" row. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
@@ -1,7 +1,9 @@
import { ContextProvider } from "@lit/context";
import { IntlMessageFormat } from "intl-messageformat";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import type { LocalizeFunc } from "../../../../src/common/translations/localize";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-picker-combo-box";
import type { PickerComboBoxItem } from "../../../../src/components/ha-picker-combo-box";
@@ -19,13 +21,26 @@ import type {
} from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
// The English strings, plurals included, so searching a demo announces the
// same count and search as the real picker.
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.search": "Search",
"ui.components.combo-box.no_match": "No matching items found",
"ui.components.combo-box.no_items": "No items available",
"ui.components.combo-box.result_count":
"{count} {count, plural,\n one {result}\n other {results}\n} for {term}",
"ui.components.combo-box.no_results": "No results for {term}",
};
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
const localize: LocalizeFunc = (key, values) => {
const message = LOCALIZE_KEYS[key];
if (message === undefined) {
return key;
}
return new IntlMessageFormat(message, "en").format<string>(
values as Record<string, string | number>
) as string;
};
const DEMO_I18N: HomeAssistantInternationalization = {
localize,
@@ -5,6 +5,7 @@ import { customElement, state } from "lit/decorators";
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 { explainTarget } from "../../../../demo/src/stubs/explain_target";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { ensureArray } from "../../../../src/common/array/ensure-array";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
@@ -225,6 +226,20 @@ class DemoHaTargetPickerExclusions
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
hass.mockWS("config/device_registry/list_composite_splits", () => ({}));
hass.mockWS("extract_from_target", extractFromTarget);
hass.mockWS("explain_target", ({ target }: { target: HassServiceTarget }) =>
explainTarget(
target,
(single) => {
const resolved = extractFromTarget({ target: single });
return {
areas: resolved.referenced_areas,
devices: resolved.referenced_devices,
entities: resolved.referenced_entities,
};
},
Object.keys(hass.states)
)
);
hass.mockWS("auth/sign_path", (params) => params);
}
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatDateTimeNumeric } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTimeNumeric extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTimeNumeric extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatDateTimeWithSeconds } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTimeSeconds extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTimeSeconds extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatShortDateTimeWithYear } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTimeShortYear extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTimeShortYear extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatShortDateTime } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTimeShort extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTimeShort extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatDateTime } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTime extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTime extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -2,7 +2,6 @@ import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { formatDateNumeric } from "../../../../src/common/datetime/format_date";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -27,7 +26,7 @@ export class DemoDateTimeDate extends LitElement {
};
const date = new Date();
return html`
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -86,7 +85,7 @@ export class DemoDateTimeDate extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatTimeWithSeconds } from "../../../../src/common/datetime/format_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeTimeSeconds extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeTimeSeconds extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatTimeWeekday } from "../../../../src/common/datetime/format_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeTimeWeekday extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeTimeWeekday extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatTime } from "../../../../src/common/datetime/format_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeTime extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeTime extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
@@ -25,6 +25,13 @@ const CONFIGS = [
entity: "media_player.music_playing",
},
},
{
heading: "Playing Music (all features)",
config: {
type: "media-control",
entity: "media_player.music_all_features",
},
},
{
heading: "Playing Stream",
config: {
@@ -81,6 +81,15 @@ const CONFIGS = [
state_color: true,
},
},
{
heading: "With custom color",
config: {
type: "sensor",
entity: "sensor.outside_temperature",
color: "teal",
graph: "line",
},
},
{
heading: "With name, icon and unit",
config: {
+114
View File
@@ -201,6 +201,50 @@ const ENTITIES = [
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.on_off_fan",
state: "on",
attributes: {
friendly_name: "Bathroom fan",
device_class: "fan",
percentage: 100,
percentage_step: 100,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.six_speed_fan",
state: "on",
attributes: {
friendly_name: "Kitchen fan",
device_class: "fan",
percentage: 50,
percentage_step: 100 / 6,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.ten_speed_fan",
state: "on",
attributes: {
friendly_name: "Living room fan",
device_class: "fan",
percentage: 70,
percentage_step: 10,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.eleven_speed_fan",
state: "on",
attributes: {
friendly_name: "Office fan",
device_class: "fan",
percentage: 45,
percentage_step: 100 / 11,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
const CONFIGS = [
@@ -410,6 +454,55 @@ const CONFIGS = [
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (5 speeds, slider style)",
config: {
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed", style: "slider" }],
},
},
{
heading: "Fan speed feature (off/on, slider style falls back to buttons)",
config: {
type: "tile",
entity: "fan.on_off_fan",
features: [{ type: "fan-speed", style: "slider" }],
},
},
{
heading: "Fan speed feature (6 speeds, slider by default)",
config: {
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (6 speeds, buttons style)",
config: {
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading: "Fan speed feature (10 speeds, buttons style)",
config: {
type: "tile",
entity: "fan.ten_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading:
"Fan speed feature (11 speeds, buttons style falls back to slider)",
config: {
type: "tile",
entity: "fan.eleven_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading: "Fan speed feature (half width)",
config: {
@@ -430,6 +523,27 @@ const CONFIGS = [
],
},
},
{
heading:
"Fan speed feature (half width, buttons fall back to a slider when they do not fit)",
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
{
type: "tile",
entity: "fan.ten_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
],
},
},
{
heading: "Fan oscillate feature",
config: {
+32 -32
View File
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.8",
"@babel/runtime": "8.0.10",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.4",
"@codemirror/commands": "6.11.1",
@@ -54,17 +54,17 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.8.2",
"@formatjs/intl-displaynames": "7.3.15",
"@formatjs/intl-durationformat": "0.11.1",
"@formatjs/intl-getcanonicallocales": "3.2.12",
"@formatjs/intl-listformat": "8.3.15",
"@formatjs/intl-locale": "5.3.12",
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.16",
"@formatjs/intl-relativetimeformat": "12.3.16",
"@home-assistant/webawesome": "3.14.0-ha.1",
"@lezer/common": "1.5.4",
"@formatjs/intl-datetimeformat": "8.0.1",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.2",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.1",
"@formatjs/intl-pluralrules": "7.0.1",
"@formatjs/intl-relativetimeformat": "13.0.1",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -78,11 +78,11 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "7.0.1",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@versatiles/style": "7.1.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@vvo/tzdb": "6.202.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.3",
"cally": "0.9.2",
@@ -100,8 +100,8 @@
"element-internals-polyfill": "3.0.2",
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.7.1",
"hls.js": "1.7.4",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"js-yaml": "5.5.0",
@@ -109,8 +109,8 @@
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.15.0",
"marked": "18.1.1",
"maplibre-gl": "6.16.0",
"marked": "18.1.2",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -132,19 +132,19 @@
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"xss": "1.0.16",
"xss": "1.0.17",
"zrender": "6.1.0"
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.9",
"@babel/core": "8.0.10",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-class-properties": "8.0.1",
"@babel/plugin-transform-private-methods": "8.0.1",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@bundle-stats/plugin-webpack-filter": "4.23.0",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
@@ -153,8 +153,8 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.9",
"@rspack/dev-server": "2.2.1",
"@rspack/core": "2.3.0",
"@rspack/dev-server": "2.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "7.0.1",
"@types/chromecast-caf-sender": "1.0.11",
@@ -173,11 +173,11 @@
"@vitest/coverage-v8": "5.0.5",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.4",
"browserslist": "4.29.5",
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.51.0",
"del": "9.0.1",
"eslint": "10.13.0",
"eslint": "10.14.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -188,8 +188,8 @@
"fancy-log": "2.0.0",
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "13.0.6",
"globals": "17.13.0",
"glob": "14.0.3",
"globals": "17.14.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -214,7 +214,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.0",
"typescript-eslint": "8.72.1",
"vite": "8.3.5",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.5",
@@ -222,8 +222,8 @@
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected]5.0",
"packageManager": "[email protected]6.1",
"volta": {
"node": "24.22.1"
"node": "24.22.2"
}
}
+24
View File
@@ -13,3 +13,27 @@ index 95366713472f3ec5f3649547bb1fe202b806a2e6..e91f99229d554e79077486e1caf3602c
originX: e.pinchX,
originY: e.pinchY,
isAvailableBehavior: null
diff --git a/lib/label/LabelManager.js b/lib/label/LabelManager.js
index 3d233be98b14223168726ebb3d9cd9b768fa2554..d6abfe5484ea8c2f0cb3a9d7c7c2fe31e7516fc7 100644
--- a/lib/label/LabelManager.js
+++ b/lib/label/LabelManager.js
@@ -43,6 +43,7 @@
*/
// TODO: move labels out of viewport.
import { BoundingRect, updateProps, initProps, isElementRemoved } from '../util/graphic.js';
+import { getAnimationConfig } from '../animation/basicTransition.js';
import { getECData } from '../util/innerStore.js';
import { parsePercent } from '../util/number.js';
import Transformable from 'zrender/lib/core/Transformable.js';
@@ -395,6 +396,11 @@ var LabelManager = /** @class */function () {
textEl.attr(layoutStore.oldLayoutEmphasis);
}
}
+ // An instant update, e.g. on roam, stops all animations and would leave the fade in half done
+ var updateConfig = getAnimationConfig('update', seriesModel, dataIndex);
+ if (!(updateConfig && updateConfig.duration > 0)) {
+ textEl.stopAnimation('enter', true);
+ }
updateProps(textEl, newProps, seriesModel, dataIndex);
}
layoutStore.oldLayout = newProps;
+63 -4
View File
@@ -1,8 +1,8 @@
diff --git a/modular/sortable.core.esm.js b/modular/sortable.core.esm.js
index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa526090a00 100644
index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd5926852b956 100644
--- a/modular/sortable.core.esm.js
+++ b/modular/sortable.core.esm.js
@@ -1781,11 +1781,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1777,11 +1777,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
}
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
capture();
@@ -24,7 +24,7 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
}
parentEl = el; // actualization
@@ -1802,7 +1807,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1798,7 +1803,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
targetRect = getRect(target);
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
capture();
@@ -38,7 +38,16 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
parentEl = el; // actualization
changed();
@@ -1849,10 +1859,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1818,7 +1828,7 @@ Sortable.prototype = /** @lends Sortable.prototype */{
pastFirstInvertThresh = false;
isCircumstantialInvert = !differentRowCol && options.invertSwap || differentLevel;
}
- direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target);
+ direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target, _isRtl(el, vertical));
var sibling;
if (direction !== 0) {
// Check if target is beside dragEl in respective direction (ignoring hidden elements)
@@ -1845,10 +1855,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
_silent = true;
setTimeout(_unsilent, 30);
capture();
@@ -58,3 +67,53 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
}
// Undo chrome's scroll adjustment (has no effect on other browsers)
@@ -2242,19 +2257,48 @@ function _disableDraggable(el) {
function _unsilent() {
_silent = false;
}
+// Horizontal RTL lists run from right to left. Mirroring the x axis lets the
+// checks below keep assuming that the list runs from left to right.
+function _isRtl(el, vertical) {
+ return !vertical && css(el, 'direction') === 'rtl';
+}
+function _mirrorX(rect) {
+ return {
+ left: -rect.right,
+ right: -rect.left,
+ top: rect.top,
+ bottom: rect.bottom,
+ width: rect.width,
+ height: rect.height
+ };
+}
function _ghostIsFirst(evt, vertical, sortable) {
var firstElRect = getRect(getChild(sortable.el, 0, sortable.options, true));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ firstElRect = _mirrorX(firstElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX < childContainingRect.left - spacer || evt.clientY < firstElRect.top && evt.clientX < firstElRect.right : evt.clientY < childContainingRect.top - spacer || evt.clientY < firstElRect.bottom && evt.clientX < firstElRect.left;
}
function _ghostIsLast(evt, vertical, sortable) {
var lastElRect = getRect(lastChild(sortable.el, sortable.options.draggable));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ lastElRect = _mirrorX(lastElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX > childContainingRect.right + spacer || evt.clientY > lastElRect.bottom && evt.clientX > lastElRect.left : evt.clientY > childContainingRect.bottom + spacer || evt.clientX > lastElRect.right && evt.clientY > lastElRect.top;
}
-function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget) {
+function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget, rtl) {
+ if (rtl) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ targetRect = _mirrorX(targetRect);
+ }
var mouseOnAxis = vertical ? evt.clientY : evt.clientX,
targetLength = vertical ? targetRect.height : targetRect.width,
targetS1 = vertical ? targetRect.top : targetRect.left,
+1011 -978
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
tslib: 2.8.1
tslib: 2.9.1
patchedDependencies:
"@material/[email protected]": patches/@[email protected]
@@ -0,0 +1,8 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="28" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="48" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="68" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 582 B

@@ -0,0 +1,8 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="28" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="48" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="68" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 584 B

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260930.0"
version = "20261028.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+18 -1
View File
@@ -13,6 +13,9 @@
"enabledManagers": ["npm", "nvm", "custom.regex", "github-actions"],
"minimumReleaseAge": "3 days",
"postUpdateOptions": ["pnpmDedupe"],
"constraints": {
"ghActionsLock": "v0.4.1"
},
"lockFileMaintenance": {
"description": ["Run after patch releases but before next beta"],
"enabled": true,
@@ -59,12 +62,26 @@
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"description": "Keep gh-actions-lock in CI and Renovate's lockfile generator on the same version",
"customType": "regex",
"managerFilePatterns": [
"/^\\.github/workflows/actions-lock\\.yaml$/",
"/^renovate\\.json$/"
],
"matchStrings": [
"GH_ACTIONS_LOCK_VERSION: (?<currentValue>v\\d+\\.\\d+\\.\\d+)",
"\"ghActionsLock\": \"(?<currentValue>v\\d+\\.\\d+\\.\\d+)\""
],
"depNameTemplate": "github/gh-actions-lock",
"datasourceTemplate": "github-releases"
},
{
"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)"
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
],
"depNameTemplate": "mcr.microsoft.com/playwright",
"datasourceTemplate": "docker",
+10 -12
View File
@@ -4,8 +4,8 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-next";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import type { AuthProvider } from "../data/auth";
declare global {
@@ -32,21 +32,19 @@ export class HaPickAuthProvider extends LitElement {
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
<ha-list-base>
${this.authProviders.map(
(provider) => html`
<ha-list-item
hasMeta
role="button"
<ha-list-item-button
.auth_provider=${provider}
@click=${this._handlePick}
>
${provider.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<span slot="headline">${provider.name}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -80,9 +78,9 @@ export class HaPickAuthProvider extends LitElement {
background: var(--card-background-color);
padding: 0 15px;
}
ha-list {
ha-list-base {
margin: 16px -16px 0;
--mdc-list-side-padding: 24px;
--ha-row-item-padding-inline: 24px;
}
`;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Return a copy of the list with the value in it, without adding duplicates
* @param values - The current list
* @param value - The value to add
*/
export const arrayWith = <T>(
values: readonly T[] | undefined,
value: T
): T[] => (values?.includes(value) ? [...values] : [...(values ?? []), value]);
/**
* Return a copy of the list without the value
* @param values - The current list
* @param value - The value to remove
*/
export const arrayWithout = <T>(
values: readonly T[] | undefined,
value: T
): T[] => (values ?? []).filter((v) => v !== value);
+34
View File
@@ -0,0 +1,34 @@
import type { VisibilityCondition } from "../../panels/lovelace/common/validate-condition";
import { ensureArray } from "../array/ensure-array";
// YAML dashboards can hold entries like `- ` (null) or a bare string.
const isConditionObject = (c: unknown): c is VisibilityCondition =>
typeof c === "object" && c !== null;
/**
* Drop entries that are not condition objects, at any depth. Returns the
* same array when every entry is valid.
*/
export const dropInvalidConditions = (
conditions: VisibilityCondition[]
): VisibilityCondition[] => {
let changed = false;
const valid: VisibilityCondition[] = [];
for (const c of conditions) {
if (!isConditionObject(c)) {
changed = true;
continue;
}
if ("conditions" in c && c.conditions != null) {
const children = ensureArray(c.conditions);
const validChildren = dropInvalidConditions(children);
if (validChildren !== children) {
changed = true;
valid.push({ ...c, conditions: validChildren } as VisibilityCondition);
continue;
}
}
valid.push(c);
}
return changed ? valid : conditions;
};
@@ -0,0 +1,128 @@
import type { ReactiveControllerHost } from "lit";
import { ref } from "lit/directives/ref";
const MAX_ROWS = 2;
// How many items fit in maxRows of a wrapping row, leaving room for the
// "more" item after them when not all of them fit.
export const fitInRows = (
widths: number[],
moreWidth: number,
rowWidth: number,
gap: number,
maxRows: number
): number => {
const fits = (end: number, width: number) =>
end === 0 || end + gap + width <= rowWidth;
let rows = 1;
let end = 0;
let fit = 0;
for (const [index, width] of widths.entries()) {
if (rows < maxRows || fits(end, moreWidth)) {
fit = index;
}
if (fits(end, width)) {
end += (end === 0 ? 0 : gap) + width;
} else {
rows++;
end = width;
}
if (rows > maxRows) {
return fit;
}
}
return widths.length;
};
// Fits the items of a wrapping legend list into two rows. The list keeps its
// "more" item rendered with the legend-more class, and items past the fit
// stay rendered out of the flow, so both can always be measured.
export class LegendFitController {
// Items to show while collapsed, undefined until the list is measured
public fit?: number;
private _host: ReactiveControllerHost;
private _element?: Element;
private _more?: Element;
private _widths: number[] = [];
private _rowWidth?: number;
private _resizeObserver?: ResizeObserver;
constructor(host: ReactiveControllerHost) {
this._host = host;
}
public target() {
return ref(this._setElement);
}
// Fits again from the current widths, for when the items change
public refit() {
this._widths = [];
this._fit();
}
private _setElement = (element?: Element) => {
if (element === this._element) {
return;
}
this._resizeObserver?.disconnect();
this._element = element;
this._more = undefined;
if (element) {
this._resizeObserver ??= new ResizeObserver(() => this._fit());
this._resizeObserver.observe(element);
}
};
private _fit() {
const list = this._element;
const rowWidth = list?.getBoundingClientRect().width;
if (!list || !rowWidth) {
return;
}
const children = Array.from(list.children);
const more = children.find((child) =>
child.classList.contains("legend-more")
);
// Switching between More and Less resizes it, not always the list
if (more !== this._more) {
if (this._more) {
this._resizeObserver?.unobserve(this._more);
}
if (more) {
this._resizeObserver?.observe(more);
}
this._more = more;
}
const widths = children
.filter((child) => child !== more)
.map((child) => child.getBoundingClientRect().width);
// Values resize the items on every state update. Fitting each item at its
// widest since the last resize keeps the more button from flickering.
if (rowWidth !== this._rowWidth || widths.length !== this._widths.length) {
this._rowWidth = rowWidth;
this._widths = widths;
} else {
this._widths = widths.map((width, index) =>
Math.max(width, this._widths[index])
);
}
const fit = fitInRows(
this._widths,
more?.getBoundingClientRect().width ?? 0,
rowWidth,
parseFloat(getComputedStyle(list).columnGap) || 0,
MAX_ROWS
);
if (fit !== this.fit) {
this.fit = fit;
this._host.requestUpdate();
}
}
}
@@ -144,7 +144,8 @@ export class MediaProgressController implements ReactiveController {
return;
}
slider.valueFormatter = formatMediaTime;
if (slider.matches(":state(dragging)")) {
// matches(":state(dragging)") throws before Chromium 125.
if (slider.customStates.has("dragging")) {
return;
}
slider.value = this.progress ?? 0;
@@ -26,8 +26,10 @@ export interface QueryParamsControllerOptions {
* Called when the page of the host is navigated to while the host is
* connected, like a second link to an open page or coming back to it. Read
* the URL with `searchParams()` and apply it, then `write()` the state back.
* Gets the `popstate` or `location-changed` event, or nothing when the host
* is connected again.
*/
navigated: () => void;
navigated: (ev?: Event) => void;
/**
* The path of the host's page, for a page that can first be connected on
* a sub-path it sends on to itself, like an add link. Defaults to the path
@@ -159,7 +161,7 @@ export class QueryParamsController<
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
this._options.navigated(ev);
}
};
+1 -1
View File
@@ -85,7 +85,7 @@ export const parseTimeString = (timeString: string, timezone: string): Date => {
* @returns true if current time matches the condition
*/
export const checkTimeInRange = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "locale" | "config">,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): boolean => {
const timezone =
+1 -1
View File
@@ -1,5 +1,5 @@
// The attributes that hold the address of a link or an image
const URL_ATTRIBUTES = [
export const URL_ATTRIBUTES = [
["a", "href"],
["img", "src"],
["source", "src"],
+15 -2
View File
@@ -1,5 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeCssColor } from "../color/compute-color";
import { computeDomain } from "./compute_domain";
import { stateColorCss } from "./state_color";
export const ALERT_DOMAINS = [
"alarm_control_panel",
@@ -8,8 +10,6 @@ export const ALERT_DOMAINS = [
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
@@ -25,3 +25,16 @@ export const computeAlertActiveStates = (entityId: string): string[] => {
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
export const computeAlertColor = (
stateObj: HassEntity,
color: string
): string | undefined => {
if (color === "none") {
return undefined;
}
if (color === "state") {
return stateColorCss(stateObj);
}
return computeCssColor(color);
};
@@ -6,6 +6,7 @@ import {
} from "../../data/entity/entity_attributes";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { FrontendLocaleData } from "../../data/translation";
import { TimeZone } from "../../data/translation";
import type { WeatherEntity } from "../../data/weather";
import { getWeatherUnit } from "../../data/weather";
import type { HomeAssistant, ValuePart } from "../../types";
@@ -115,6 +116,24 @@ export const computeAttributeValueToParts = (
}
}
// Date only, which is timezone agnostic, so parse and format it as
// local time to keep the same calendar day
if (isDate(attributeValue)) {
const date = new Date(`${attributeValue}T00:00`);
if (checkValidDate(date)) {
return [
{
type: "value",
value: formatDate(
date,
{ ...locale, time_zone: TimeZone.local },
config
),
},
];
}
}
// Value was not a timestamp, so only do date formatting
const date = new Date(attributeValue);
if (checkValidDate(date)) {
@@ -3,7 +3,10 @@ import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant, HomeAssistantRegistries } from "../../types";
import type {
HomeAssistantInternationalization,
HomeAssistantRegistries,
} from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
@@ -211,16 +214,20 @@ export interface EntityPickerDisplay {
secondary?: string;
}
export interface EntityPickerDisplayOptions extends HomeAssistantRegistries {
language: HomeAssistantInternationalization["language"];
translationMetadata: HomeAssistantInternationalization["translationMetadata"];
}
export const computeEntityPickerDisplay = (
hass: HomeAssistantRegistries &
Pick<HomeAssistant, "language" | "translationMetadata">,
options: EntityPickerDisplayOptions,
stateObj: HassEntity
): EntityPickerDisplay => {
const parts = computeEntityNameParts(stateObj, hass);
const parts = computeEntityNameParts(stateObj, options);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
options.language,
options.translationMetadata.translations
);
const primary = parts.entity || parts.device || stateObj.entity_id;
+37
View File
@@ -20,6 +20,11 @@ export interface EntityFilter {
export type EntityFilterFunc = (entityId: string) => boolean;
export type EntityFilterSources = Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>;
const normalizeFilterArray = <T>(
value: T | null | T[] | (T | null)[] | undefined
): Set<T | null> | undefined => {
@@ -157,3 +162,35 @@ export const findEntities = (
return results;
};
export const excludeEntities = (
entityIds: string[],
excludedEntityIds?: string[]
): string[] => {
if (!excludedEntityIds?.length) {
return entityIds;
}
const excluded = new Set(excludedEntityIds);
return entityIds.filter((entityId) => !excluded.has(entityId));
};
export const findFilteredEntities = (
sources: EntityFilterSources,
filters: EntityFilter[],
excludedEntityIds?: string[]
): string[] => {
const filterFuncs = filters.map((filter) =>
generateEntityFilter(
sources.states,
sources.entities,
sources.devices,
sources.areas,
sources.floors,
filter
)
);
return excludeEntities(
findEntities(Object.keys(sources.states), filterFuncs),
excludedEntityIds
);
};
+12 -13
View File
@@ -1,6 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { stringCompare } from "../string/compare";
import { computeDomain } from "./compute_domain";
import { computeStateDomain } from "./compute_state_domain";
@@ -248,7 +247,8 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
};
export const getStatesDomain = (
hass: HomeAssistant,
states: HassEntities,
language: string,
domain: string,
attribute?: string | undefined
): string[] => {
@@ -274,15 +274,13 @@ export const getStatesDomain = (
case "person":
if (!attribute) {
result.push(
...Object.entries(hass.states)
...Object.entries(states)
.filter(
([entityId]) =>
computeDomain(entityId) === "zone" && entityId !== "zone.home"
)
.map(([_entityId, stateObj]) => computeZoneTrackerState(stateObj))
.sort((zone1, zone2) =>
stringCompare(zone1, zone2, hass.locale.language)
)
.sort((zone1, zone2) => stringCompare(zone1, zone2, language))
);
}
break;
@@ -370,23 +368,24 @@ export const getValueAttribute = (
): string | undefined => DOMAIN_VALUE_ATTRIBUTES[domain]?.[optionsAttribute];
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
states: HassEntities,
language: string,
stateObj: HassEntity,
attribute: string | undefined = undefined
): string[] => {
const domain = computeStateDomain(state);
const domain = computeStateDomain(stateObj);
const result: string[] = [];
// Fixed values based on a domain
result.push(...getStatesDomain(hass, domain, attribute));
result.push(...getStatesDomain(states, language, domain, attribute));
// Dynamic values based on the entities
const optionsAttribute = getOptionsAttribute(domain, attribute);
if (optionsAttribute) {
const options = state.attributes[optionsAttribute];
const options = stateObj.attributes[optionsAttribute];
// Sensors only expose their options when their device class is `enum`.
const enumSensor =
domain !== "sensor" || state.attributes.device_class === "enum";
domain !== "sensor" || stateObj.attributes.device_class === "enum";
if (enumSensor && Array.isArray(options)) {
result.push(...options);
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
};
export const toggleGroupEntities = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callService">,
states: HassEntity[]
) => {
if (!states.length) {
+12 -7
View File
@@ -16,8 +16,8 @@ import {
// other one is built in the browser. The attribution comes from the TileJSON,
// deliberately: it follows whoever serves the tiles.
const SHIPPED_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
light: `${__STATIC_PATH__}map/light.json`,
dark: `${__STATIC_PATH__}map/dark.json`,
} as const;
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
@@ -28,7 +28,7 @@ const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
@@ -92,8 +92,12 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
// The origin that serves the frontend's files: the page, except for the demo
// embed on another site
const filesBase = () => new URL(__STATIC_PATH__, location.href);
// MapLibre rejects a relative sprite URL
const absoluteSprite = (url: string) => new URL(url, location.href).href;
const absoluteSprite = (url: string) => new URL(url, filesBase()).href;
// Shared only while in flight, or a style that failed once would never be
// fetched again. Text rather than a parsed style: MapLibre mutates what it
@@ -125,7 +129,7 @@ export const loadStyle = async (
).buildMapStyle(
mapStyle.palette,
mapStyle.options ?? {},
mapStyle.baseColors
mapStyle.styleOptions
);
if (typeof style.sprite === "string") {
@@ -141,14 +145,15 @@ export const loadStyle = async (
// Global to MapLibre; set once before the first map is created. Absolute,
// because on Cast the page is not served from the instance. The URL comes
// from the build: the worker is an entry of its own, next to the app.
// from the build: the worker is an entry of its own, next to the app. MapLibre
// starts a worker from another origin itself.
let workerUrlSet = false;
export const ensureWorkerUrl = (setUrl: typeof setWorkerUrl) => {
if (workerUrlSet) {
return;
}
workerUrlSet = true;
setUrl(new URL(__MAPLIBRE_WORKER_URL__, location.href).href);
setUrl(new URL(__MAPLIBRE_WORKER_URL__, filesBase()).href);
};
const createVectorLayer = async (
+5 -9
View File
@@ -16,7 +16,7 @@ import { finalizeMapStyle } from "./map-style-transforms";
*/
// Versioned: /static is served with a month of max-age, so a cached manifest
// could otherwise pair a new build with the sprite sheet of an old one.
const STYLE_URLS_PATH = `/static/map/urls.json?v=${__VERSION__}`;
const STYLE_URLS_PATH = `${__STATIC_PATH__}map/urls.json?v=${__VERSION__}`;
let urls: Promise<OsmOptions["urls"]> | undefined;
@@ -35,7 +35,7 @@ const styleUrls = () => {
export const buildMapStyle = async (
palette: MapPalette,
options: Record<string, unknown>,
baseColors?: Record<string, string>
styleOptions?: Record<string, unknown>
): Promise<StyleSpecification> => {
const [{ osm }, resolvedUrls] = await Promise.all([
import("@versatiles/style"),
@@ -50,13 +50,9 @@ export const buildMapStyle = async (
// The builder throws on an option it does not know and on a color it cannot
// parse. Those come from a card config or a theme, so one typo would
// otherwise drop the whole card to raster tiles. Step back to the style as it
// ships - colors included, or the default would come out as the builder's
// unpainted cartography - and only then to the bare palette.
const attempts = [
options,
...(baseColors ? [{ colors: baseColors }] : []),
{},
];
// comes - colors and adjustments included, or the default would come out as
// the builder's unpainted cartography - and only then to the bare palette.
const attempts = [options, ...(styleOptions ? [styleOptions] : []), {}];
let style: StyleSpecification | undefined;
for (const attempt of attempts) {
try {
@@ -78,7 +78,7 @@ const POSITIONS: Record<
bottomright: "bottom-right",
};
const MAPLIBRE_CSS_URL = "/static/map/maplibre-gl.css";
const MAPLIBRE_CSS_URL = `${__STATIC_PATH__}map/maplibre-gl.css`;
// Fully hidden behind the globe, not MapLibre's faint ghost
const MARKER_OPTIONS = { opacityWhenCovered: 0 };
+74 -16
View File
@@ -1,11 +1,12 @@
// The vector cartographies the map can draw. Each is a light and a dark
// palette; the theme mode picks the half.
// palette; the theme mode picks the half, unless the style only has one.
//
// Only the default pair is generated at build time (build-scripts/gulp/
// map-assets.js); everything else is built in the browser from the same
// builder, against the tiles and glyphs core's proxy already serves.
import { HA_MAP_COLORS, HA_MAP_COLORS_DARK } from "./ha-map-palette";
import { NEON_MAP_COLORS_DARK, NEON_MAP_OPTIONS } from "./neon-map-palette";
/** Style ids, in the order the card editor offers them */
export const MAP_STYLES = [
@@ -15,6 +16,7 @@ export const MAP_STYLES = [
"muted",
"gray",
"toner",
"neon",
] as const;
export type MapStyle = (typeof MAP_STYLES)[number];
@@ -26,13 +28,19 @@ type VersatilesPalette = "colorful" | "natural" | "muted" | "gray" | "toner";
export type MapPalette = VersatilesPalette | `${VersatilesPalette}-dark`;
/** A cartography plus the colors painted over it; only the default has any */
/**
* A cartography plus what is painted over it; the VersaTiles palettes have
* nothing. The color sets say which halves a style has: both, or neither, and
* the theme mode picks; only one, and that is the half the map draws.
*/
const STYLE_PALETTES: Record<
MapStyle,
{
palette: VersatilesPalette;
colors?: Record<string, string>;
colorsDark?: Record<string, string>;
/** Builder options the style comes with, by option group */
options?: Record<string, Record<string, unknown>>;
}
> = {
default: {
@@ -40,6 +48,11 @@ const STYLE_PALETTES: Record<
colors: HA_MAP_COLORS,
colorsDark: HA_MAP_COLORS_DARK,
},
neon: {
palette: "colorful",
colorsDark: NEON_MAP_COLORS_DARK,
options: NEON_MAP_OPTIONS,
},
colorful: { palette: "colorful" },
natural: { palette: "natural" },
muted: { palette: "muted" },
@@ -96,11 +109,12 @@ export interface ResolvedMapStyle {
/** Fetched rather than built: the default, while nothing was added to it */
shipped?: "light" | "dark";
/**
* The style's own colors, without what the theme or the card painted over
* them. What to fall back on when the builder rejects an adjustment, so a
* bad value costs the adjustments rather than the cartography.
* The style as it comes: its colors and its own adjustments, without what
* the theme or the card painted over them. What to fall back on when the
* builder rejects an adjustment, so a bad value costs the card's adjustments
* rather than the style.
*/
baseColors?: Record<string, string>;
styleOptions?: Record<string, unknown>;
}
const isStyle = (value: unknown): value is MapStyle =>
@@ -110,9 +124,31 @@ export const isCustomMapStyle = (
style: MapStyleConfig | undefined
): style is CustomMapStyleConfig => typeof style === "object" && style !== null;
export const paletteFor = (style: MapStyle, darkMode: boolean): MapPalette => {
const toStyle = (style: MapStyleConfig | undefined): MapStyle => {
const named = isCustomMapStyle(style) ? style.base : style;
return isStyle(named) ? named : DEFAULT_MAP_STYLE;
};
/**
* Whether the map comes out dark: the theme mode's answer, unless the style
* only has the one half. What the map's own chrome and the theme tokens have
* to follow, rather than the theme mode.
*/
export const isDarkMapStyle = (
style: MapStyleConfig | undefined,
darkMode: boolean
): boolean => {
const { colors, colorsDark } = STYLE_PALETTES[toStyle(style)];
if (Boolean(colors) !== Boolean(colorsDark)) {
return Boolean(colorsDark);
}
return darkMode;
};
/** The half to draw; `dark` is the map's, from isDarkMapStyle */
export const paletteFor = (style: MapStyle, dark: boolean): MapPalette => {
const { palette } = STYLE_PALETTES[style];
return darkMode ? `${palette}-dark` : palette;
return dark ? `${palette}-dark` : palette;
};
// An allowlist, not a passthrough: the builder also takes the tile, glyph and
@@ -198,19 +234,33 @@ export const resolveMapStyle = (
themeColors?: Record<string, string>
): ResolvedMapStyle => {
const custom = isCustomMapStyle(style) ? style : undefined;
const named = custom ? custom.base : style;
const mapStyle = isStyle(named) ? named : DEFAULT_MAP_STYLE;
const mapStyle = toStyle(style);
// A dark-only style picks the dark half of everything, not just the palette.
const dark = isDarkMapStyle(style, darkMode);
const palette = paletteFor(mapStyle, darkMode);
const options = custom ? builderOptions(custom, darkMode) : {};
const palette = paletteFor(mapStyle, dark);
const options = custom ? builderOptions(custom, dark) : {};
// Before the colors below are merged in, so it answers whether the card
// asked for anything of its own.
const cardAdjusted = Object.keys(options).length > 0;
// The style's own adjustments go under the card's, group by group, so a
// card that sets one recolor value keeps the style's others.
const {
colors,
colorsDark,
options: styleOptions,
} = STYLE_PALETTES[mapStyle];
for (const [group, values] of Object.entries(styleOptions ?? {})) {
options[group] = {
...values,
...(options[group] as Record<string, unknown> | undefined),
};
}
// Bottom up: the style's colors, what the theme repaints, then what the card
// names - so a card that sets one color keeps the rest of the look.
const { colors, colorsDark } = STYLE_PALETTES[mapStyle];
const styleColors = (darkMode ? colorsDark : colors) ?? colors;
const styleColors = dark ? colorsDark : colors;
if (styleColors || themeColors) {
options.colors = {
...styleColors,
@@ -223,11 +273,19 @@ export const resolveMapStyle = (
return { palette };
}
const base = styleColors ? { baseColors: styleColors } : {};
const own = styleColors || styleOptions;
const base = own
? {
styleOptions: {
...styleOptions,
...(styleColors && { colors: styleColors }),
},
}
: {};
// Anything added on top of the shipped default has to be built here.
const untouched = !themeColors && !cardAdjusted;
return mapStyle === DEFAULT_MAP_STYLE && untouched
? { palette, options, ...base, shipped: darkMode ? "dark" : "light" }
? { palette, options, ...base, shipped: dark ? "dark" : "light" }
: { palette, options, ...base };
};
+13
View File
@@ -0,0 +1,13 @@
import { isAndroidDevice } from "../../util/is_android";
import { isIosDevice } from "../../util/is_ios";
export const navigationUrl = (latitude: number, longitude: number): string => {
const point = `${latitude},${longitude}`;
if (isIosDevice()) {
return `https://maps.apple.com/?daddr=${point}`;
}
if (isAndroidDevice()) {
return `geo:${point}?q=${point}`;
}
return `https://www.openstreetmap.org/directions?to=${encodeURIComponent(point)}#map=15/${latitude}/${longitude}`;
};
+64
View File
@@ -0,0 +1,64 @@
// The "neon" style: the VersaTiles "colorful" cartography repainted as a night
// map, glowing roads on near-black ground. A dark set and no light one, which
// is what makes the style dark whatever the theme mode.
export const NEON_MAP_COLORS_DARK = {
background: "#07021a",
land: "#07021a",
water: "#13215e",
labelWater: "#00e5ff",
glacier: "#1b2a6b",
natureWood: "#0d1f2e",
natureGrass: "#101a33",
naturePark: "#12203a",
natureLeisure: "#111a30",
natureAgriculture: "#0d1528",
natureWetland: "#0e2440",
natureSand: "#1a1230",
natureRock: "#140c28",
areaResidential: "#0c0524",
areaCommercial: "#140430",
areaIndustrial: "#10062a",
areaWaste: "#160a2e",
areaBurial: "#0d0a26",
siteParking: "#0f0728",
siteSports: "#0a1c2c",
siteEducation: "#160a34",
siteHospital: "#2a0a2a",
siteDanger: "#ff1744",
sitePrison: "#1c0a28",
siteConstruction: "#3a1d5c",
building: "#160a33",
buildingBg: "#8a2be2",
roadStreet: "#00e5ff",
roadStreetBg: "#062b52",
roadSecondary: "#8f7bff",
roadSecondaryBg: "#241058",
roadTrunk: "#ff6ec7",
roadTrunkBg: "#4a0f38",
roadMotorway: "#ff2d95",
roadMotorwayBg: "#5c0033",
transitRail: "#7b2cff",
transitSubway: "#b14cff",
transitCycle: "#00ffa3",
transitFoot: "#2e2a55",
boundary: "#39ff14",
boundaryDisputed: "#1f7a0d",
label: "#ff9ad5",
labelHalo: "rgba(7,2,26,0.9)",
labelSymbol: "#ff9ad5",
labelPoi: "#00e5ff",
labelShield: "#12002a",
labelHousenumber: "rgba(0,229,255,0.35)",
};
/** Builder options the style comes with, under whatever a card adds */
export const NEON_MAP_OPTIONS = {
recolor: { saturate: 0.35, contrast: 1.15 },
text: {
transform: "uppercase",
letterSpacing: 0.12,
haloWidth: 2,
haloBlur: 0,
},
};
Loaded 100 of 792 files, more files were not shown because too many files have changed in this diff. Show more