Compare commits

..
Author SHA1 Message Date
Wendelin ef7792e1fe Add loading skeletons and refactor loading state in HuiElementEditor 2026-11-04 11:53:56 +01: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
292 changed files with 10059 additions and 2790 deletions

No files matched your search

+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
+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 }}
+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
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
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
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
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
@@ -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
+3
View File
@@ -284,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),
@@ -291,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: [
+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")
);
});
};
+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),
};
}),
};
};
+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);
@@ -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);
}
+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: {
+12 -12
View File
@@ -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",
"@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.4",
"@lezer/common": "1.5.5",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -89,7 +89,7 @@
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.51.0",
"cropperjs": "2.3.1",
"cropperjs": "1.7.0",
"culori": "4.0.2",
"date-fns": "4.4.0",
"decimal.js": "10.6.0",
@@ -177,7 +177,7 @@
"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",
+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,
+206 -308
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

+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",
+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"],
+11 -6
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.
@@ -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,
},
};
@@ -1,14 +0,0 @@
import type {
ListItem,
RequestSelectedDetail,
} from "@material/mwc-list/mwc-list-item";
export const shouldHandleRequestSelectedEvent = (
ev: CustomEvent<RequestSelectedDetail>
): boolean => {
if (!ev.detail.selected || ev.detail.source !== "property") {
return false;
}
(ev.currentTarget as ListItem).selected = false;
return true;
};
+10 -1
View File
@@ -1,3 +1,4 @@
import { SYSTEM_LOG_LEVELS, type SystemLogLevel } from "../../data/system_log";
import {
decodeQueryParams,
hasQueryParams,
@@ -17,7 +18,7 @@ export const logSearchQueryParamConfig = {
// `raw` shows the full Home Assistant Core log instead of the errors.
export const logsQueryParamConfig = {
string: ["provider", "search", "filter"],
list: ["integration"],
list: ["integration", "level"],
boolean: [{ key: "raw", trueValue: "1" }],
} as const satisfies QueryParamConfig;
@@ -28,13 +29,19 @@ export interface LogsState {
search: string;
// Only apply to the Home Assistant Core log
integrations: string[];
// Only apply to the Home Assistant Core errors, not the raw log
levels: SystemLogLevel[];
raw: boolean;
}
const isLevel = (level: string): level is SystemLogLevel =>
(SYSTEM_LOG_LEVELS as readonly string[]).includes(level);
export const DEFAULT_LOGS_STATE: LogsState = {
provider: DEFAULT_LOG_PROVIDER,
search: "",
integrations: [],
levels: [],
raw: false,
};
@@ -53,6 +60,7 @@ export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
search: state.search,
integration:
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
level: state.provider === DEFAULT_LOG_PROVIDER ? state.levels : undefined,
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
});
@@ -71,6 +79,7 @@ export const logsLinkFromUrl = (
provider,
search: params.search ?? params.filter ?? "",
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
levels: core ? (params.level?.filter(isLevel) ?? []) : [],
raw: core && !!params.raw,
};
};
@@ -1,4 +1,4 @@
import type { ContextType } from "@lit/context";
import { provide, type ContextType } from "@lit/context";
import { mdiCommentTextOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -17,9 +17,14 @@ import { getEntityTarget } from "../../panels/config/automation/target/get_entit
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../ha-svg-icon";
import "../ha-tooltip";
import { targetExpandGroupContext } from "../target-picker/target-expand-group-context";
@customElement("ha-automation-condition-summary")
export class HaAutomationConditionSummary extends LitElement {
// Triggers and conditions don't expand the groups their targets name.
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
@property() public label = "";
@property({ attribute: false }) public condition?: Condition;
@@ -67,3 +67,88 @@ export const itemTooltipPosition: TooltipPositionCallback = (
return [x, y];
};
const ECHARTS_TOOLTIP_GAP_PX = 20;
export const WINDOW_MARGIN_PX = 8;
const fitInto = (
pos: number,
size: number,
alternatives: number[],
min: number,
max: number
) =>
size > max - min
? min
: ([pos, ...alternatives].find((p) => p >= min && p + size <= max) ??
Math.max(min, Math.min(pos, max - size)));
/**
* ECharts' default placement (or the chart-confined one), moved into the
* window when it would leave it. The other side of the cursor is tried before
* clamping, below first vertically. A tooltip taller than the window starts at
* its top. A confined tooltip stays in the chart, leaving it only as far as the
* window needs.
*/
export const windowTooltipPosition =
(
getChartRect: () => DOMRect | undefined,
confine: boolean
): TooltipPositionCallback =>
(point, _params, _dom, _rect, size) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const right = cursorX + ECHARTS_TOOLTIP_GAP_PX;
const left = cursorX - ECHARTS_TOOLTIP_GAP_PX - tipW;
const below = cursorY + ECHARTS_TOOLTIP_GAP_PX;
const above = cursorY - ECHARTS_TOOLTIP_GAP_PX - tipH;
let x = right + tipW + 2 > viewW ? left : right;
let y = below + tipH > viewH ? above : below;
if (confine) {
x = Math.max(0, Math.min(x, viewW - tipW));
y = Math.max(0, Math.min(y, viewH - tipH));
}
const rect = getChartRect();
if (!rect?.height) {
return [x, y];
}
const fit = (
pos: number,
tipSize: number,
alternatives: number[],
windowMin: number,
windowMax: number,
viewSize: number
) => {
if (!confine) {
return fitInto(pos, tipSize, alternatives, windowMin, windowMax);
}
const min = Math.max(0, windowMin);
const max = Math.min(viewSize, windowMax);
return tipSize > max - min
? fitInto(pos, tipSize, [], windowMin, windowMax)
: fitInto(pos, tipSize, alternatives, min, max);
};
const { clientWidth, clientHeight } = document.documentElement;
return [
fit(
x,
tipW,
[right, left],
WINDOW_MARGIN_PX - rect.left,
clientWidth - WINDOW_MARGIN_PX - rect.left,
viewW
),
fit(
y,
tipH,
[below, above],
WINDOW_MARGIN_PX - rect.top,
clientHeight - WINDOW_MARGIN_PX - rect.top,
viewH
),
];
};
+12 -1
View File
@@ -68,6 +68,7 @@ import { ZOOM_SLIDER_HEIGHT } from "./ha-chart-zoom-slider";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { windowTooltipPosition } from "./chart-tooltip-position";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -1252,9 +1253,19 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
next.appendTo = undefined;
}
if (next.position === undefined) {
// ECharts confines after the position callback, which would undo the
// window fit, so the callback confines itself
next.position = windowTooltipPosition(
() => this._chartContainer?.getBoundingClientRect(),
isMobile || next.confine === true
);
next.confine = false;
} else if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
}
if (isMobile || this._touchInput) {
next.triggerOn = "click";
+10 -4
View File
@@ -31,8 +31,14 @@ export interface NetworkNode {
color?: string;
borderColor?: string;
borderWidth?: number;
opacity?: number;
};
label?: {
position?: "left" | "right" | "top" | "bottom";
show?: boolean;
fontWeight?: "normal" | "bold";
fontSize?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -51,6 +57,7 @@ export interface NetworkLink {
width?: number;
color?: string;
type?: "solid" | "dashed" | "dotted";
opacity?: number;
};
symbolSize?: number | number[];
symbol?: string;
@@ -286,9 +293,8 @@ export class HaNetworkGraph extends LitElement {
return node.name ?? "";
},
rich: {
primary: {
fontSize: 12,
},
// inherits the node label font, so a node can override it
primary: {},
secondary: {
fontSize: 12,
color: getComputedStyle(document.body).getPropertyValue(
+55 -42
View File
@@ -22,6 +22,8 @@ export interface Node {
color?: string;
passThrough?: boolean;
entityId?: string;
// Nodes of one group take a single position when ordering their children
group?: string;
}
export interface Link {
source: string;
@@ -46,6 +48,46 @@ const NODE_SIZE = 30;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
// Links without a value take the remaining capacity of their nodes, in order.
export const fillSankeyLinkValues = (
nodes: Node[],
rawLinks: Link[]
): ProcessedLink[] => {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
};
@customElement("ha-sankey-chart")
export class HaSankeyChart extends LitElement {
@property({ attribute: false }) public data: SankeyChartData = {
@@ -190,7 +232,7 @@ export class HaSankeyChart extends LitElement {
);
}
});
const links = this._processLinks(filteredNodes, data.links);
const links = fillSankeyLinkValues(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
@@ -304,60 +346,31 @@ export class HaSankeyChart extends LitElement {
private _createData = memoizeOne(this._computeData);
private _processLinks(nodes: Node[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
}
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
const parents = links.filter((l) => l.target === id).map((l) => l.source);
if (parents.length === 0) {
return -1;
}
const countedGroups = new Set<string>();
let sum = 0;
let count = 0;
for (const parent of parents) {
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
sum += offset + index;
const positions = [...new Set(sections[i].map((n) => n.group ?? n.id))];
const node = sections[i].find((n) => n.id === parent);
if (node) {
if (node.group !== undefined) {
if (countedGroups.has(node.group)) {
break;
}
countedGroups.add(node.group);
}
sum += offset + positions.indexOf(node.group ?? node.id);
count++;
break;
}
offset += section.length;
offset += positions.length;
}
}
return count > 0 ? sum / count : -1;
@@ -161,7 +161,8 @@ export abstract class HaDeviceAutomationPicker<
return html`<ha-generic-picker
.label=${this.label}
.value=${value}
.disabled=${!this._automations || this._automations.length === 0}
.disabled=${this._automations?.length === 0}
.loading=${this._automations === undefined}
.getItems=${this._getItems(
value,
this._automations,
+37 -1
View File
@@ -189,6 +189,10 @@ export class HaStatisticPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _loadFailed = false;
private _statisticIdsRequest = 0;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
public willUpdate(changedProps: PropertyValues<this>) {
@@ -201,11 +205,36 @@ export class HaStatisticPicker extends LitElement {
}
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this._api, this.statisticTypes);
// Changing the statistic types starts a new request, and only the latest
// one may update the picker
const request = ++this._statisticIdsRequest;
this._loadFailed = false;
let statisticIds: StatisticsMetaData[];
try {
statisticIds = await getStatisticIds(this._api, this.statisticTypes);
} catch {
if (request === this._statisticIdsRequest) {
this._loadFailed = true;
}
return;
}
if (request !== this._statisticIdsRequest) {
return;
}
this.statisticIds = statisticIds;
this._picker?.requestUpdate();
this._valueRenderer = this._makeValueRenderer();
}
private _retryGetStatisticIds(ev: Event) {
ev.stopPropagation();
this._getStatisticIds();
}
private _getItems = () =>
this._getStatisticsItems(
this._states,
@@ -589,6 +618,12 @@ export class HaStatisticPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.loading=${!this._loadFailed && this.statisticIds === undefined}
.loadError=${
this._loadFailed
? this._i18n.localize("ui.components.statistic-picker.load_failed")
: undefined
}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@@ -598,6 +633,7 @@ export class HaStatisticPicker extends LitElement {
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged}
@picker-retry=${this._retryGetStatisticIds}
>
</ha-generic-picker>
`;
+55 -1
View File
@@ -1,10 +1,11 @@
import type { ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import {
@@ -25,6 +26,9 @@ import type { IntegrationManifest } from "../data/integration";
import { domainToName } from "../data/integration";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-badge";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
@@ -32,6 +36,14 @@ import type { HaListSelectable } from "./list/ha-list-selectable";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
export interface IntegrationFilterCount {
key: string;
path: string;
count: number;
label: string;
color?: string;
}
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public value?: string[];
@@ -40,6 +52,12 @@ export class HaFilterIntegrations extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
/** Counts to show beside each integration, by domain. */
@property({ attribute: false }) public counts?: Record<
string,
IntegrationFilterCount[]
>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -129,6 +147,7 @@ export class HaFilterIntegrations extends LitElement {
brand-fallback
></ha-domain-icon>
<span slot="headline">${integration.name}</span>
${this._renderCounts(integration.domain)}
</ha-list-item-option>`
)}
</ha-list-selectable>`
@@ -140,6 +159,32 @@ export class HaFilterIntegrations extends LitElement {
`;
}
private _renderCounts(domain: string): TemplateResult | typeof nothing {
const counts = this.counts?.[domain];
if (!counts?.length) {
return nothing;
}
return html`<span slot="supporting-text" class="counts">
${counts.map(
(item) =>
html`<ha-badge
id="count-${domain}-${item.key}"
role="img"
aria-label=${item.label}
style=${styleMap({ "--badge-color": item.color })}
>
<ha-svg-icon slot="icon" .path=${item.path}></ha-svg-icon>
${item.count}
</ha-badge>
<ha-tooltip for="count-${domain}-${item.key}">
${item.label}
</ha-tooltip>`
)}
</span>`;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -249,6 +294,15 @@ export class HaFilterIntegrations extends LitElement {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
.counts {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-1);
margin-top: var(--ha-space-1);
--ha-badge-size: 22px;
--ha-badge-icon-size: 14px;
--ha-badge-font-size: var(--ha-font-size-xs);
}
`,
];
}
+161 -43
View File
@@ -2,7 +2,7 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
import { mdiPlaylistPlus, mdiRefresh } from "@mdi/js";
import {
css,
html,
@@ -17,13 +17,15 @@ import { styleMap } from "lit/directives/style-map";
import { tinykeys } from "tinykeys";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { configContext } from "../data/context";
import { configContext, internationalizationContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import { isIosApp } from "../util/is_ios";
import "./ha-alert";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-picker-combo-box";
import type {
@@ -33,6 +35,7 @@ import type {
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import "./ha-svg-icon";
import "./skeleton/ha-skeleton";
@customElement("ha-generic-picker")
export class HaGenericPicker extends PickerMixin(LitElement) {
@@ -114,6 +117,15 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-unknown-state" })
public noUnknownState = false;
/** Shows a skeleton in place of the field while its items load. */
@property({ type: Boolean, reflect: true }) public loading = false;
/**
* Shows this error with a retry button in place of the field when its items
* failed to load. The retry button fires `picker-retry`.
*/
@property({ attribute: false }) public loadError?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -124,6 +136,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
// Set once loading starts, then hidden with CSS once it ends, so the
// skeleton fades out over the field
@state() private _skeletonShown = false;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@@ -155,9 +175,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("loading") && this.loading) {
this._skeletonShown = true;
}
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState")
changedProperties.has("noUnknownState") ||
changedProperties.has("loading") ||
changedProperties.has("loadError")
) {
this._setUnknownValue();
}
@@ -183,45 +208,73 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: nothing
}
<div id="picker">
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
<div class="field" ?inert=${this.loading || !!this.loadError}>
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
</div>
${
this._skeletonShown
? html`<ha-skeleton
class="loading-skeleton"
role=${ifDefined(this.loading ? "progressbar" : undefined)}
aria-label=${ifDefined(
this.loading ? this._loadingLabel() : undefined
)}
></ha-skeleton>`
: nothing
}
${
this.loadError
? html`<div class="load-error">
<ha-alert alert-type="error">
${this.loadError}
<ha-icon-button
slot="action"
.path=${mdiRefresh}
.label=${this._i18n?.localize("ui.common.retry")}
@click=${this._retry}
></ha-icon-button>
</ha-alert>
</div>`
: nothing
}
</div>
${
this._pickerWrapperOpen || this._opened
@@ -298,9 +351,25 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
`;
}
private _loadingLabel() {
const label = this.ariaLabel || this.label || this.placeholder;
return label
? this._i18n?.localize("ui.components.generic-picker.loading", {
label,
})
: this._i18n?.localize("ui.common.loading");
}
private _retry(ev: Event) {
ev.stopPropagation();
fireEvent(this, "picker-retry");
}
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.loading ||
this.loadError ||
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
@@ -413,7 +482,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
) {
ev?.stopPropagation();
if (this.disabled) {
if (this.disabled || this.loading || this.loadError) {
return;
}
this._selectedValue = options?.selectedValue;
@@ -459,6 +529,53 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
display: block;
max-width: 100%;
}
/* The error shares the field's cell, so a wrapping error grows the
picker instead of covering what follows */
#picker {
position: relative;
display: grid;
}
#picker > .field,
#picker > .load-error {
grid-area: 1 / 1;
min-width: 0;
}
/* The field keeps its place under the skeleton, so nothing moves and
the two cross fade once loading ends */
.field {
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
:host([loading]) .field,
.field[inert] {
opacity: 0;
}
.loading-skeleton {
position: absolute;
inset: 0;
--ha-skeleton-border-radius: var(--ha-border-radius-sm);
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
@starting-style {
.loading-skeleton {
opacity: 0;
}
}
/* Hidden once faded out, and at once when no fade runs */
:host(:not([loading])) .loading-skeleton {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease-out,
visibility 0s linear var(--ha-animation-duration-fast, 150ms);
}
.load-error {
display: flex;
align-items: center;
}
.load-error ha-alert {
flex: 1;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
@@ -540,5 +657,6 @@ declare global {
interface HASSDomEvents {
"picker-closed": undefined;
"picker-opened": undefined;
"picker-retry": undefined;
}
}
+2
View File
@@ -86,6 +86,7 @@ class HaMarkdownElement extends ReactiveElement {
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
breaks: this.breaks,
rewriteUrl: Boolean(this.rewriteUrl),
});
}
@@ -99,6 +100,7 @@ class HaMarkdownElement extends ReactiveElement {
{
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
allowRelativeUrls: Boolean(this.rewriteUrl),
}
);
+2 -1
View File
@@ -178,7 +178,8 @@ export class HaNavigationPicker extends LitElement {
allow-custom-value
.placeholder=${this.label}
.helper=${this.helper}
.disabled=${this._loading || this.disabled}
.disabled=${this.disabled}
.loading=${this._loading}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
+6
View File
@@ -477,6 +477,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
.section-id=${section.id}
.selected=${this._selectedSection === section.id}
.label=${section.label}
aria-label=${
this.i18n?.localize?.(
"ui.components.combo-box.filter_by_section",
{ section: section.label }
) || section.label
}
>
</ha-filter-chip>`
)}
@@ -162,7 +162,7 @@ export class HaObjectSelector extends LitElement {
description = this._formatField(item, descriptionField);
}
const reorderable = this.selector.object!.multiple || false;
const reorderable = this._reorderable;
const multiple = this.selector.object!.multiple || false;
return html`
<ha-list-item-base class="item">
@@ -212,6 +212,7 @@ export class HaObjectSelector extends LitElement {
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
<ha-sortable
.disabled=${!this._reorderable}
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${this._itemMoved}
@@ -291,11 +292,18 @@ export class HaObjectSelector extends LitElement {
}));
});
private get _reorderable(): boolean {
return (
(this.selector.object?.multiple ?? false) &&
this.selector.object?.reorder !== false
);
}
private _itemMoved(ev) {
ev.stopPropagation();
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
if (!this.selector.object!.multiple) {
if (!this._reorderable) {
return;
}
const newValue = ensureArray(this.value ?? []).concat();
+187 -1
View File
@@ -1,7 +1,14 @@
import TabGroup from "@home-assistant/webawesome/dist/components/tab-group/tab-group";
import { css, type CSSResultGroup } from "lit";
import { css, type CSSResultGroup, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import type { SortableEvent } from "sortablejs";
import { DragScrollController } from "../common/controllers/drag-scroll-controller";
import { fireEvent } from "../common/dom/fire_event";
import type { SortableInstance } from "../resources/sortable";
const TOUCH_DRAG_DELAY_MS = 300;
const AUTO_SCROLL_SENSITIVITY = 30;
@customElement("ha-tab-group")
export class HaTabGroup extends TabGroup {
@@ -13,24 +20,199 @@ export class HaTabGroup extends TabGroup {
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
/**
* Allow reordering tabs by dragging or with Alt+ArrowUp/ArrowDown. Fires
* `item-moved`; the owner applies the new order.
*/
@property({ type: Boolean }) public sortable = false;
/** Drag handle that starts a touch drag without the long press. */
@property({ attribute: "handle-selector" }) public handleSelector?: string;
private _sortable?: SortableInstance;
private _sortPlaceholder?: Comment;
connectedCallback(): void {
super.connectedCallback();
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
// which browsers use for back/forward navigation.
this.addEventListener("keydown", this._handleKeyDown, true);
if (this.hasUpdated && this.sortable) {
this._createSortable();
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeyDown, true);
this._destroySortable();
}
protected override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has("sortable")) {
// Dragging the tab bar to scroll would compete with dragging a tab.
this._dragScrollController.enabled = !this.sortable;
if (this.sortable) {
this._createSortable();
} else {
this._destroySortable();
}
}
}
private async _createSortable() {
if (this._sortable) {
return;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
const Sortable = (await import("../resources/sortable")).default;
if (this._sortable || !this.sortable || !this.isConnected) {
return;
}
// supportPointer is missing from the SortableJS typings.
const options: SortableInstance.Options & { supportPointer?: boolean } = {
draggable: this.tabTag,
// SortableJS guesses vertical from the tabs being display: block.
direction:
this.placement === "start" || this.placement === "end"
? "vertical"
: "horizontal",
// The tab bar scrolls inside the shadow root, where SortableJS can't find it.
scroll: this.nav,
// Force the autoscroll fallback because it works better than the native one
forceAutoScrollFallback: true,
scrollSensitivity: AUTO_SCROLL_SENSITIVITY,
scrollSpeed: 20,
animation: 150,
// Keep swiping on touch screens for scrolling the tab bar.
delay: TOUCH_DRAG_DELAY_MS,
delayOnTouchOnly: true,
// A scrolling swipe ends in pointercancel, after which SortableJS
// ignores the next drag. Touch events end with touchend instead.
supportPointer: false,
onStart: this._handleSortableStart,
onEnd: this._handleSortableEnd,
onUpdate: this._handleSortableUpdate,
};
this._sortable = new Sortable(this, options);
// Capture, so the delay is set before SortableJS reads it.
this.addEventListener("touchstart", this._handleTouchStart, {
capture: true,
passive: true,
});
}
private _destroySortable() {
this._sortable?.destroy();
this._sortable = undefined;
this.removeEventListener("touchstart", this._handleTouchStart, true);
this._removeRtlAutoScrollListeners();
}
private _handleTouchStart = (ev: TouchEvent) => {
const onHandle =
!!this.handleSelector &&
ev
.composedPath()
.some(
(el) => el instanceof Element && el.matches(this.handleSelector!)
);
this._sortable!.option("delay", onHandle ? 0 : TOUCH_DRAG_DELAY_MS);
};
private _handleSortableStart = (evt: SortableEvent) => {
// Put the tab back after dropping, the owner renders the new order.
this._sortPlaceholder = document.createComment("sort-placeholder");
evt.item.after(this._sortPlaceholder);
// Touch drags add a copy of the tab to this element, which the tab group
// would show and select as an extra tab. The dimmed tab shows the position.
this.querySelector(":scope > .sortable-fallback")?.remove();
if (getComputedStyle(this.nav).direction !== "rtl") {
return;
}
// Capture, so this runs before SortableJS checks for auto scroll.
const options = { capture: true, passive: true };
window.addEventListener("dragover", this._unblockRtlAutoScroll, options);
window.addEventListener("touchmove", this._unblockRtlAutoScroll, options);
};
private _removeRtlAutoScrollListeners() {
window.removeEventListener("dragover", this._unblockRtlAutoScroll, true);
window.removeEventListener("touchmove", this._unblockRtlAutoScroll, true);
}
// SortableJS only auto scrolls left while scrollLeft isn't 0, but in RTL the
// tab bar starts at 0 and scrolls left into negative values.
private _unblockRtlAutoScroll = (ev: DragEvent | TouchEvent) => {
const x = "touches" in ev ? ev.touches[0]?.clientX : ev.clientX;
if (x === undefined || this.nav.scrollLeft !== 0) {
return;
}
const left = this.nav.getBoundingClientRect().left;
if (Math.abs(x - left) <= AUTO_SCROLL_SENSITIVITY) {
this.nav.scrollLeft = -1;
}
};
private _handleSortableEnd = (evt: SortableEvent) => {
this._removeRtlAutoScrollListeners();
this._sortPlaceholder?.replaceWith(evt.item);
this._sortPlaceholder = undefined;
};
private _handleSortableUpdate = (evt: SortableEvent) => {
fireEvent(this, "item-moved", {
oldIndex: evt.oldIndex!,
newIndex: evt.newIndex!,
});
};
private _handleKeyDown = (event: KeyboardEvent) => {
// The tab group cancels Enter and Space, so buttons inside a tab would not
// be pressed.
if (
(event.key === "Enter" || event.key === " ") &&
event.composedPath()[0] instanceof HTMLButtonElement &&
(event.target as HTMLElement).closest(this.tabTag)
) {
event.stopPropagation();
return;
}
if (event.altKey || event.metaKey) {
event.stopPropagation();
}
if (
this.sortable &&
event.altKey &&
!(event.ctrlKey || event.metaKey) &&
!event.shiftKey &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
this._moveTabWithKeyboard(event);
}
};
// Up and down follow the tab order, so they work the same in RTL.
private _moveTabWithKeyboard(event: KeyboardEvent) {
const tab = (event.target as HTMLElement).closest(this.tabTag);
if (!tab) {
return;
}
const tabs = [...this.children].filter(
(el) => el.localName === this.tabTag
);
const oldIndex = tabs.indexOf(tab);
const newIndex = oldIndex + (event.key === "ArrowUp" ? -1 : 1);
event.preventDefault();
if (oldIndex === -1 || newIndex < 0 || newIndex >= tabs.length) {
return;
}
fireEvent(this, "item-moved", { oldIndex, newIndex });
}
protected override handleClick(event: MouseEvent) {
if (this._dragScrollController.scrolled) {
return;
@@ -67,6 +249,10 @@ export class HaTabGroup extends TabGroup {
margin-inline-start: calc(-1 * var(--track-width));
}
::slotted(.sortable-ghost) {
opacity: 0.4;
}
.scroll-button::part(base):hover {
background-color: transparent;
}
+22 -219
View File
@@ -41,9 +41,7 @@ import { domainToName } from "../data/integration";
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import {
extractFromTarget,
getTargetComboBoxItemType,
type ExtractFromTargetResult,
type HassServiceTargetWithExclude,
type TargetItem,
type TargetType,
@@ -70,21 +68,15 @@ import "./list/ha-list-base";
import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-item-row";
import { showTargetDetailsDialog } from "./target-picker/dialog/show-dialog-target-details";
import { filterTargetEntries } from "./target-picker/filter-target-entries";
import { TargetExclusionsController } from "./target-picker/target-exclusions-controller";
import {
addToExclude,
applyExclusionChanges,
computeExclusionsPerTarget,
hasIncludedTargets,
removeFromExclude,
resolveTargetItemEntities,
TARGET_TYPES,
targetItemKey,
targetItems,
withExclude,
type ResolvedTargetItem,
type TargetExclusion,
type TargetExclusions,
} from "./target-picker/target-exclusions";
const SEPARATOR = "________";
@@ -151,24 +143,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _excludeSection?: TargetTypeFloorless;
// What each exclusion takes out of each target, worked out from the value.
@state() private _exclusions?: TargetExclusions;
// Whether every included target and every exclusion resolved, so it is
// known what each exclusion takes out, which ones are shared with another
// target, and the details can edit them.
@state() private _exclusionsComplete = true;
// Every entity the included targets resolve to, so the exclude picker only
// offers what would take something out.
private _includedEntities?: string[];
private _resolveRequest = 0;
// What each target item resolves to, so a change only asks core about the
// items it adds.
private _itemEntries = new Map<string, Promise<ExtractFromTargetResult>>();
@state() private _replaceTarget?: TargetItem;
@state() private _replaceTargetAnchor?: HTMLElement;
@@ -189,6 +163,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _newTarget?: TargetItem;
private _exclusions = new TargetExclusionsController(this);
private _getDevicesMemoized = memoizeOne(
(
hass: HomeAssistant,
@@ -302,9 +278,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
(devicesChanged ||
this.hass.entities !== changedProps.get("hass")?.entities ||
this.hass.areas !== changedProps.get("hass")?.areas);
if (registriesChanged) {
this._itemEntries.clear();
}
if (
changedProps.has("value") ||
changedProps.has("allowExclude") ||
@@ -315,7 +288,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
changedProps.has("entityFilter") ||
registriesChanged
) {
this._resolveExclusions();
this._exclusions.resolve();
}
if (
@@ -351,12 +324,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
? ensureArray(this.value?.label_id)
: [];
const noEffect = this._exclusions?.noEffect ?? [];
const unplaced = this._unplacedExclusions(
this.value?.exclude,
this._exclusions
);
const perTarget = this._exclusionsPerTarget();
const noEffect = this._exclusions.exclusions?.noEffect ?? [];
const unplaced = this._exclusions.unplaced;
const perTarget = this._exclusions.perTarget;
if (
!entityIds.length &&
@@ -497,25 +467,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
`;
}
// Excludes that aren't listed under a target or as having no effect,
// because they are still being resolved or resolving them failed.
private _unplacedExclusions = memoizeOne(
(
exclude: HassServiceTarget | undefined,
exclusions: TargetExclusions | undefined
): TargetItem[] => {
const placed = new Set([
...Object.values(exclusions?.perTarget ?? {}).flatMap((list) =>
list.map(targetItemKey)
),
...(exclusions?.noEffect ?? []).map(targetItemKey),
]);
return targetItems(exclude).filter(
(item) => !placed.has(targetItemKey(item))
);
}
);
private _renderItems() {
return html` ${this._renderValueGroups()} `;
}
@@ -612,155 +563,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
];
}
private _extractItem(
item: TargetItem,
primaryEntitiesOnly: boolean
): Promise<ExtractFromTargetResult> {
const key = `${primaryEntitiesOnly}:${targetItemKey(item)}`;
let entries = this._itemEntries.get(key);
if (!entries) {
entries = extractFromTarget(
this.hass.callWS,
{ [`${item.type}_id`]: [item.id] },
false,
primaryEntitiesOnly
);
this._itemEntries.set(key, entries);
const cached = entries;
cached.catch(() => {
if (this._itemEntries.get(key) === cached) {
this._itemEntries.delete(key);
}
});
}
return entries;
}
// The entities an included item adds, filtered like its row so what an
// exclusion takes out matches what the row counts.
private async _resolveInclude(item: TargetItem): Promise<string[]> {
if (item.type === "entity") {
return resolveTargetItemEntities(this.hass, item);
}
const entries = await this._extractItem(
item,
this.primaryEntitiesOnly ?? true
);
return filterTargetEntries(
this.hass.states,
this.hass.entities,
this.hass.devices,
this.hass.areas,
item,
entries,
{
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
primaryEntitiesOnly: this.primaryEntitiesOnly,
}
).referenced_entities;
}
// Like core, an exclude takes out every entity of an item, not only the
// primary ones, and isn't narrowed by the filters.
private async _resolveExclude(item: TargetItem): Promise<string[]> {
if (item.type === "entity") {
return resolveTargetItemEntities(this.hass, item);
}
return (await this._extractItem(item, false)).referenced_entities;
}
private async _resolveExclusions() {
const request = ++this._resolveRequest;
this._includedEntities = undefined;
const value = this.value;
const excludes = targetItems(value?.exclude);
const includes = targetItems(value);
// Without an exclude, the targets are only needed to tell whether an
// exclusion made in the details is shared with another target.
if (
!this.hass ||
(!excludes.length && (!this.allowExclude || includes.length < 2))
) {
this._exclusions = undefined;
this._exclusionsComplete = true;
return;
}
// Until the targets resolve, the exclusions shown are from before and
// may not say which ones are shared now.
this._exclusionsComplete = false;
// Each item on its own, so it is known which exclusion takes out what
// from which target. One that fails only loses its own part.
const [includeResults, excludeResults] = await Promise.all([
Promise.allSettled(includes.map((item) => this._resolveInclude(item))),
Promise.allSettled(excludes.map((item) => this._resolveExclude(item))),
]);
if (request !== this._resolveRequest) {
return;
}
const resolvedIncludes: ResolvedTargetItem[] = [];
includes.forEach((item, index) => {
const result = includeResults[index];
if (result.status === "fulfilled") {
resolvedIncludes.push({ ...item, entities: result.value });
}
});
const resolvedExcludes: ResolvedTargetItem[] = [];
excludes.forEach((item, index) => {
const result = excludeResults[index];
if (result.status === "fulfilled") {
resolvedExcludes.push({ ...item, entities: result.value });
}
});
const allIncluded = resolvedIncludes.length === includes.length;
const exclusions = computeExclusionsPerTarget(
resolvedIncludes,
resolvedExcludes
);
if (!allIncluded) {
// Without every target, no effect can't be told from not known, and a
// shared exclusion can't be told from one that isn't.
exclusions.noEffect = [];
}
this._exclusions = exclusions;
this._exclusionsComplete =
allIncluded && resolvedExcludes.length === excludes.length;
this._includedEntities = allIncluded
? [
...new Set(
includeResults.flatMap((result) =>
result.status === "fulfilled" ? result.value : []
)
),
]
: undefined;
}
// Without an exclude yet, what the target includes is only needed once the
// exclude picker opens.
private async _resolveIncludedEntities() {
const request = this._resolveRequest;
try {
const included = await Promise.all(
targetItems(this.value).map((item) => this._resolveInclude(item))
);
if (request !== this._resolveRequest) {
return;
}
this._includedEntities = [...new Set(included.flat())];
this._excludePicker?.refreshItems();
} catch (_err) {
// The picker then offers everything instead.
}
}
private _openExcludePicker(ev: Event) {
// exclude picker opens, so it only offers what would take something out.
private async _openExcludePicker(ev: Event) {
this._excludePicker?.open(ev);
if (!this._includedEntities) {
this._resolveIncludedEntities();
if (!this._exclusions.includedEntities) {
await this._exclusions.resolve({ force: true });
this._excludePicker?.refreshItems();
}
}
@@ -791,27 +600,17 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
private _exclusionsPerTarget():
Record<string, TargetExclusion[]> | undefined {
return (
this._exclusions?.perTarget ??
(this.allowExclude && !hasIncludedTargets(this.value?.exclude)
? {}
: undefined)
);
}
private _openDetails(ev: HASSDomEvent<HASSDomEvents["open-target-details"]>) {
ev.stopPropagation();
const { type, id, title } = ev.detail;
const key = targetItemKey({ type, id });
const perTarget = this._exclusionsPerTarget();
const perTarget = this._exclusions.perTarget;
const exclusions = perTarget ? (perTarget[key] ?? []) : undefined;
const editable =
exclusions &&
this.allowExclude &&
!this.disabled &&
this._exclusionsComplete;
this._exclusions.complete;
showTargetDetailsDialog(this, {
title,
type,
@@ -824,9 +623,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
primaryEntitiesOnly: this.primaryEntitiesOnly,
exclusions,
exclude: this.value?.exclude,
otherTargetEntities: Object.entries(
this._exclusions?.included ?? {}
).flatMap(([targetKey, entities]) => (targetKey === key ? [] : entities)),
expandGroup: this._exclusions.expandGroup,
explainExclusion: editable
? (item) => this._exclusions.explainExclusion(key, item)
: undefined,
exclusionsChanged: editable
? (initial, current) => {
fireEvent(this, "value-changed", {
@@ -888,7 +688,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return this._getItemsMemoized(
this.hass.localize,
this._excludeEntityFilter(this.entityFilter, this._includedEntities),
this._excludeEntityFilter(
this.entityFilter,
this._exclusions.includedEntities
),
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
+24 -13
View File
@@ -45,7 +45,7 @@ import {
distanceMeters,
} from "../../common/map/map-engine";
import type { MapStyleConfig } from "../../common/map/map-styles";
import { resolveMapStyle } from "../../common/map/map-styles";
import { isDarkMapStyle, resolveMapStyle } from "../../common/map/map-styles";
import { readMapThemeColors } from "../../common/map/map-theme-colors";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
@@ -618,15 +618,15 @@ export class HaMap extends ReactiveElement {
});
}
if (changedProps.has("mapStyle")) {
this._engine?.setMapStyle(this._resolvedMapStyle);
}
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
) {
// A style can have one half only, so the chrome follows it too
if (changedProps.has("mapStyle")) {
this._updateMapAppearance();
}
return;
}
@@ -647,6 +647,11 @@ export class HaMap extends ReactiveElement {
);
}
// What the map draws, which a style with one half decides over the theme mode
private get _styleDark() {
return isDarkMapStyle(this.mapStyle, this._darkMode);
}
// Memoized: it is read on every appearance sync, and the engines compare
// what they are handed against what they applied.
private _resolveMapStyle = memoizeOne(resolveMapStyle);
@@ -659,12 +664,12 @@ export class HaMap extends ReactiveElement {
// arguments, and a fresh object per read would defeat it.
private _readThemeColors(): void {
// Only one set of --ha-color-map-* is live on the page, the one for the
// mode the page is in. A card forced to the other mode draws the other
// palette, so those values would be the wrong half of the theme; the
// style's own colors are the better answer there.
// mode the page is in. A card forced to the other mode, or a style with
// only the other half, draws the other palette, so those values would be
// the wrong half of the theme; the style's own colors are the better answer.
const pageDark = Boolean(this._ui?.themes?.darkMode);
const colors =
this._darkMode === pageDark ? readMapThemeColors(this) : undefined;
this._styleDark === pageDark ? readMapThemeColors(this) : undefined;
if (!deepEqual(colors, this._themeColors)) {
this._themeColors = colors;
}
@@ -690,13 +695,19 @@ export class HaMap extends ReactiveElement {
return;
}
map.classList.toggle("clickable", this.clickable);
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("dark", this._styleDark);
map.classList.toggle("drawn", this._mapDrawn);
// The sky belongs behind a drawn globe; on a blank canvas it is just a
// gradient with a glow in it
map.classList.toggle("space", this._vectorEngine && this._mapDrawn);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
map.classList.toggle(
"forced-dark",
this._styleDark && (this.themeMode === "dark" || !this._darkMode)
);
map.classList.toggle(
"forced-light",
!this._styleDark && (this.themeMode === "light" || this._darkMode)
);
this._engine?.setMapStyle(this._resolvedMapStyle);
}
@@ -765,7 +776,7 @@ export class HaMap extends ReactiveElement {
map.id = "map";
// Which ground shows in the gap before the first frame; the rest of the
// classes wait for the engine (_updateMapAppearance)
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("dark", this._styleDark);
this.shadowRoot!.append(map);
this._loading = true;
this._mapDrawn = false;
@@ -28,12 +28,7 @@ import "../../ha-svg-icon";
import "../../list/ha-list-base";
import "../ha-target-picker-item-row";
import type { TargetExclusion } from "../target-exclusions";
import {
isShared,
resolveItemExclusions,
resolveTargetItemEntities,
toggleTargetRow,
} from "../target-exclusions";
import { resolveItemExclusions, toggleTargetRow } from "../target-exclusions";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@customElement("ha-dialog-target-details")
@@ -54,10 +49,6 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private _exclusionsRequest = 0;
private _otherTargetEntities = memoizeOne(
(entities?: string[]) => new Set(entities)
);
private _deviceIntegrationLookup = memoizeOne(
(entities: HomeAssistant["entities"]) =>
getDeviceIntegrationLookup(Object.values(entities))
@@ -83,7 +74,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
params.exclude,
params.selector
? params.selector.target?.primary_entities_only
: params.primaryEntitiesOnly
: params.primaryEntitiesOnly,
params.expandGroup
);
} catch (_err) {
// The details still show the target, only without what is excluded.
@@ -292,37 +284,32 @@ class DialogTargetDetails extends LitElement implements HassDialog {
...ev.detail,
exclusions: before,
initial: this._initialExclusions,
otherTargetEntities: this._otherTargetEntities(
this._params?.otherTargetEntities
),
});
this._exclusions
.filter(
(exclusion) =>
exclusion.type !== "entity" &&
!before.includes(exclusion) &&
!this._initialExclusions.includes(exclusion)
)
.forEach((exclusion) => this._resolveRemoved(exclusion));
.forEach((exclusion) => this._explainExclusion(exclusion));
}
private async _resolveRemoved(exclusion: TargetExclusion) {
let removed: string[];
private async _explainExclusion(exclusion: TargetExclusion) {
const explain = this._params?.explainExclusion;
if (!explain) {
return;
}
let explained: TargetExclusion | undefined;
try {
removed = await resolveTargetItemEntities(this.hass, exclusion, false);
explained = await explain({ type: exclusion.type, id: exclusion.id });
} catch (_err) {
return;
}
if (!this._exclusions.includes(exclusion)) {
if (!explained || !this._exclusions.includes(exclusion)) {
return;
}
const shared = isShared(
removed,
this._otherTargetEntities(this._params?.otherTargetEntities)
);
this._exclusions = this._exclusions.map((current) =>
current === exclusion ? { ...exclusion, removed, shared } : current
current === exclusion ? explained : current
);
}
@@ -2,7 +2,7 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { TargetSelector } from "../../../data/selector";
import type { TargetType } from "../../../data/target";
import type { TargetItem, TargetType } from "../../../data/target";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import type { TargetExclusion } from "../target-exclusions";
@@ -25,10 +25,16 @@ export interface TargetDetailsDialogParams {
*/
exclude?: HassServiceTarget;
/**
* The entities of the other included targets, so an exclusion made here
* is known to also affect them.
* Whether the target expands the groups it names when it is used, like an
* action does and a trigger or condition doesn't. Defaults to `true`.
*/
otherTargetEntities?: string[];
expandGroup?: boolean;
/**
* What excluding `item` would take out of this target, worked out by core
* with the rest of the target value, so it is also known whether it
* affects another target.
*/
explainExclusion?: (item: TargetItem) => Promise<TargetExclusion | undefined>;
/**
* Set to give the rows checkboxes. Called on apply with the exclusions the
* dialog opened with and the ones it has now.
@@ -1,4 +1,5 @@
import {
mdiChartLine,
mdiClose,
mdiDevices,
mdiHome,
@@ -57,6 +58,11 @@ import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import {
getStatisticMetadata,
isExternalStatistic,
type StatisticsMetaData,
} from "../../data/recorder";
import {
deviceMeetsFilter,
extractFromTarget,
@@ -177,6 +183,9 @@ export class HaTargetPickerItemRow extends LitElement {
@state() private _entries?: ExtractFromTargetResult;
/** Metadata of an external statistic picked as entity, which has no state. */
@state() private _statistic?: StatisticsMetaData;
private _fetchedEntries?: ExtractFromTargetResult;
private _lastFetch = 0;
@@ -773,6 +782,19 @@ export class HaTargetPickerItemRow extends LitElement {
this._fetchedEntries = undefined;
if (this.type === "entity") {
this._entries = undefined;
this._statistic = undefined;
if (isExternalStatistic(this.itemId)) {
try {
const [metadata] = await getStatisticMetadata(this._api.callWS, [
this.itemId,
]);
if (requestId === this._lastFetch) {
this._statistic = metadata;
}
} catch {
// Without metadata the statistic is shown as not found
}
}
return;
}
try {
@@ -859,6 +881,15 @@ export class HaTargetPickerItemRow extends LitElement {
}
if (type === "entity") {
const stateObject: HassEntity | undefined = this._states?.[item];
// External statistics have no state, only the metadata the recorder keeps.
if (!stateObject && this._statistic?.statistic_id === item) {
return {
name: this._statistic.name || item,
context: domainToName(this._i18n.localize, this._statistic.source),
fallbackIconPath: mdiChartLine,
notFound: false,
};
}
const { primary, secondary } = stateObject
? computeEntityPickerDisplay(
{
@@ -0,0 +1,224 @@
import type { ReactiveControllerHost } from "lit";
import memoizeOne from "memoize-one";
import {
consumeContext,
ContextController,
} from "../../common/decorators/consume";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
explainTarget,
type HassServiceTargetWithExclude,
type TargetItem,
} from "../../data/target";
import type { HomeAssistant } from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { filterTargetEntries } from "./filter-target-entries";
import { targetExpandGroupContext } from "./target-expand-group-context";
import {
computeExclusionsPerTarget,
hasIncludedTargets,
targetItemKey,
targetItems,
type TargetExclusion,
type TargetExclusions,
type TargetNarrowing,
} from "./target-exclusions";
export type TargetExclusionsHost = ReactiveControllerHost &
HTMLElement & {
hass: HomeAssistant;
value?: HassServiceTargetWithExclude;
allowExclude: boolean;
primaryEntitiesOnly?: boolean;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDomains?: string[];
includeDeviceClasses?: string[];
};
/**
* What the exclusions of a target picker's value take out of each of its
* targets, as core resolves them, narrowed to what the picker's rows show.
*/
export class TargetExclusionsController extends ContextController {
/** What each exclusion takes out of each target, worked out from the value. */
public exclusions?: TargetExclusions;
/**
* Whether the exclusions belong to the current value, so it is known which
* ones are shared with another target and the details can edit them.
*/
public complete = true;
private _host: TargetExclusionsHost;
@consumeContext({ context: targetExpandGroupContext, subscribe: true })
private _expandGroup?: boolean;
private _request = 0;
constructor(host: TargetExclusionsHost) {
super(host);
this._host = host;
}
protected contextUpdated() {
this.resolve();
}
/** Whether the target expands the groups it names, like an action does. */
public get expandGroup(): boolean {
return this._expandGroup ?? true;
}
/**
* Works the exclusions out again. Without an exclude, the targets are only
* needed to tell whether an exclusion made in the details is shared with
* another target, so they are only resolved for that or when `force`d.
*/
public async resolve({ force = false }: { force?: boolean } = {}) {
const request = ++this._request;
const { hass, value, allowExclude } = this._host;
if (
!hass ||
!value ||
(!force &&
!hasIncludedTargets(value.exclude) &&
(!allowExclude || targetItems(value).length < 2))
) {
this._update(undefined, true);
return;
}
// Until the targets resolve, the exclusions shown are from before and
// may not say which ones are shared now.
this._update(this.exclusions, false);
let exclusions: TargetExclusions | undefined;
try {
exclusions = computeExclusionsPerTarget(
await this._explain(value),
this._keeps()
);
} catch (_err) {
// Without an answer, the exclusions stay unplaced.
}
if (request === this._request) {
this._update(exclusions, !!exclusions);
}
}
/** The exclusions by the target they affect, once there can be any. */
public get perTarget(): Record<string, TargetExclusion[]> | undefined {
return (
this.exclusions?.perTarget ??
(this._host.allowExclude && !hasIncludedTargets(this._host.value?.exclude)
? {}
: undefined)
);
}
/** Every entity the targets include, once known for the current value. */
public get includedEntities(): string[] | undefined {
return this.complete ? this._includedEntities(this.exclusions) : undefined;
}
/**
* Excludes that aren't listed under a target or as having no effect,
* because they are still being resolved or resolving them failed.
*/
public get unplaced(): TargetItem[] {
return this._unplaced(this._host.value?.exclude, this.exclusions);
}
/**
* What excluding `item` on its own would take out of the target
* `targetKey`, with the rest of the value as it is, and whether it affects
* another target too.
*/
public async explainExclusion(
targetKey: string,
item: TargetItem
): Promise<TargetExclusion | undefined> {
const { value } = this._host;
if (!value) {
return undefined;
}
const explained = await this._explain({
...value,
exclude: { [`${item.type}_id`]: [item.id] },
});
return computeExclusionsPerTarget(explained, this._keeps()).perTarget[
targetKey
]?.[0];
}
private _explain(target: HassServiceTargetWithExclude) {
return explainTarget(
this._host.hass.callWS,
target,
this.expandGroup,
this._host.primaryEntitiesOnly ?? true
);
}
private _update(exclusions: TargetExclusions | undefined, complete: boolean) {
this.exclusions = exclusions;
this.complete = complete;
this._host.requestUpdate();
}
// What the picker's filters leave of a target, narrowed like the target's
// row, so what an exclusion takes out matches what the row counts.
private _keeps(): TargetNarrowing {
const {
hass,
deviceFilter,
entityFilter,
includeDomains,
includeDeviceClasses,
primaryEntitiesOnly,
} = this._host;
return (include) =>
filterTargetEntries(
hass.states,
hass.entities,
hass.devices,
hass.areas,
include,
{
referenced_areas: include.referenced_areas,
referenced_devices: include.referenced_devices,
referenced_entities: include.entities,
},
{
deviceFilter,
entityFilter,
includeDomains,
includeDeviceClasses,
primaryEntitiesOnly,
}
).referenced_entities;
}
private _includedEntities = memoizeOne(
(exclusions: TargetExclusions | undefined): string[] | undefined =>
exclusions && [...new Set(Object.values(exclusions.included).flat())]
);
private _unplaced = memoizeOne(
(
exclude: HassServiceTargetWithExclude["exclude"],
exclusions: TargetExclusions | undefined
): TargetItem[] => {
const placed = new Set([
...Object.values(exclusions?.perTarget ?? {}).flatMap((list) =>
list.map(targetItemKey)
),
...(exclusions?.noEffect ?? []).map(targetItemKey),
]);
return targetItems(exclude).filter(
(item) => !placed.has(targetItemKey(item))
);
}
);
}
@@ -1,9 +1,11 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../common/array/ensure-array";
import {
extractFromTarget,
explainTarget,
type ExtractFromTargetResultReferenced,
type HassServiceTargetWithExclude,
type TargetExplanation,
type TargetIncludeEntities,
type TargetItem,
type TargetType,
} from "../../data/target";
@@ -34,30 +36,6 @@ export const targetItems = (target?: HassServiceTarget): TargetItem[] =>
export const hasIncludedTargets = (target?: HassServiceTarget): boolean =>
targetItems(target).length > 0;
/** A target item with the entities it resolves to. */
export interface ResolvedTargetItem extends TargetItem {
entities: string[];
}
/** The entities a single target item resolves to. */
export const resolveTargetItemEntities = async (
hass: HomeAssistant,
item: TargetItem,
primaryEntitiesOnly?: boolean
): Promise<string[]> => {
if (item.type === "entity") {
return item.id === "all" ? Object.keys(hass.states) : [item.id];
}
return (
await extractFromTarget(
hass.callWS,
{ [`${item.type}_id`]: [item.id] },
false,
primaryEntitiesOnly
)
).referenced_entities;
};
/** An exclusion as seen from one included target. */
export interface TargetExclusion extends TargetItem {
/** Entities it takes out of that target. */
@@ -76,26 +54,42 @@ export interface TargetExclusions {
}
/**
* Which exclusions take what out of each included target. Excludes are
* global, so one exclusion is listed under every target it affects; one that
* affects none, including one whose item no longer exists, has no effect.
* The entities the picker's filters leave in an included target other than
* an entity. An entity item is what it is, filters or not.
*/
export type TargetNarrowing = (include: TargetIncludeEntities) => string[];
/**
* Which exclusions take what out of each included target, from what core
* explained. Excludes are global, so one exclusion is listed under every
* target it affects; one that affects none, including one whose item no
* longer exists, has no effect. `keeps` narrows each target to what is left
* of it once the picker's filters are applied; what an exclusion takes out
* of it is narrowed the same way.
*/
export const computeExclusionsPerTarget = (
includes: ResolvedTargetItem[],
excludes: ResolvedTargetItem[]
explained: TargetExplanation,
keeps?: TargetNarrowing
): TargetExclusions => {
const included = Object.fromEntries(
explained.includes.map((include) => [
targetItemKey(include),
include.type === "entity" || !keeps ? include.entities : keeps(include),
])
);
const perTarget: Record<string, TargetExclusion[]> = {};
const noEffect: TargetItem[] = [];
excludes.forEach((exclude) => {
const excluded = new Set(exclude.entities);
const hits = includes
.map((include) => ({
include,
removed: [...new Set(include.entities)].filter((entityId) =>
excluded.has(entityId)
),
}))
explained.excludes.forEach((exclude) => {
const hits = exclude.removed
.map((target) => {
const key = targetItemKey(target);
const entities = new Set(included[key] ?? []);
return {
key,
removed: target.entities.filter((entityId) => entities.has(entityId)),
};
})
.filter(({ removed }) => removed.length > 0);
if (!hits.length) {
@@ -103,8 +97,7 @@ export const computeExclusionsPerTarget = (
return;
}
hits.forEach(({ include, removed }) => {
const key = targetItemKey(include);
hits.forEach(({ key, removed }) => {
if (!perTarget[key]) {
perTarget[key] = [];
}
@@ -117,41 +110,58 @@ export const computeExclusionsPerTarget = (
});
});
const included = Object.fromEntries(
includes.map((include) => [targetItemKey(include), include.entities])
);
// An action replaces a group it targets directly by its members, but the
// group's row is the group itself. Once every member is out, so is the
// group, and each exclusion counts it like its row does.
explained.includes.forEach((include) => {
const key = targetItemKey(include);
const exclusions = perTarget[key];
const members = included[key];
if (
include.type !== "entity" ||
!exclusions ||
!members.length ||
members.includes(include.id)
) {
return;
}
const removed = new Set(
exclusions.flatMap((exclusion) => exclusion.removed)
);
if (members.every((entityId) => removed.has(entityId))) {
perTarget[key] = exclusions.map((exclusion) => ({
...exclusion,
removed: [include.id],
}));
}
});
return { perTarget, noEffect, included };
};
/** The exclusions that take entities out of one item of a target. */
/**
* The exclusions that take entities out of one item of a target. Actions
* expand the groups a target names, triggers and conditions don't, so
* `expandGroup` follows what the target is used for.
*/
export const resolveItemExclusions = async (
hass: HomeAssistant,
item: TargetItem,
exclude: HassServiceTarget | undefined,
primaryEntitiesOnly?: boolean
primaryEntitiesOnly?: boolean,
expandGroup = true
): Promise<TargetExclusion[]> => {
const excludes = targetItems(exclude);
if (!excludes.length) {
if (!hasIncludedTargets(exclude)) {
return [];
}
// Like core, an exclude takes out every entity of an item, not only the
// primary ones.
const [entities, excludeEntities] = await Promise.all([
resolveTargetItemEntities(hass, item, primaryEntitiesOnly),
Promise.all(
excludes.map((exclusion) =>
resolveTargetItemEntities(hass, exclusion, false)
)
),
]);
const explained = await explainTarget(
hass.callWS,
{ [`${item.type}_id`]: [item.id], exclude },
expandGroup,
primaryEntitiesOnly
);
return (
computeExclusionsPerTarget(
[{ ...item, entities }],
excludes.map((exclusion, index) => ({
...exclusion,
entities: excludeEntities[index],
}))
).perTarget[targetItemKey(item)] ?? []
computeExclusionsPerTarget(explained).perTarget[targetItemKey(item)] ?? []
);
};
@@ -369,7 +379,9 @@ export const targetRowState = (
* excluded as such, including what is added to it later. The target can't
* be excluded from itself, so unchecking it unchecks its rows instead.
* `initial` gives back an exclusion that was there before as it was. A new
* one is shared when it takes out any of `otherTargetEntities`.
* one takes out the row's entities and counts as shared until core tells
* what it takes out of every target, so a parent row can't take it away
* before it is known whether it also affects another target.
*/
export const toggleTargetRow = ({
row,
@@ -377,14 +389,12 @@ export const toggleTargetRow = ({
selected,
exclusions,
initial,
otherTargetEntities,
}: {
row: TargetRow;
isTarget: boolean;
selected: boolean;
exclusions: TargetExclusion[];
initial: TargetExclusion[];
otherTargetEntities?: ReadonlySet<string>;
}): TargetExclusion[] => {
if (!selected && isTarget) {
return row.children
@@ -397,7 +407,6 @@ export const toggleTargetRow = ({
selected: false,
exclusions: result,
initial,
otherTargetEntities,
}),
exclusions
);
@@ -417,19 +426,11 @@ export const toggleTargetRow = ({
type: row.type,
id: row.id,
removed: row.entities,
shared: isShared(row.entities, otherTargetEntities),
shared: true,
},
];
};
/** Whether an exclusion taking out `removed` also affects another target. */
export const isShared = (
removed: string[],
otherTargetEntities?: ReadonlySet<string>
): boolean =>
!!otherTargetEntities &&
removed.some((entityId) => otherTargetEntities.has(entityId));
/**
* The new `exclude` once the details are applied: what was taken away is
* removed, what was added is added, and everything else stays as it was.
@@ -0,0 +1,9 @@
import { createContext } from "@lit/context";
/**
* Whether the targets below expand the groups they name when they are used.
* Actions do, so that is what holds when nothing provides it. Triggers and
* conditions don't, so their editors provide `false`.
*/
export const targetExpandGroupContext =
createContext<boolean>("targetExpandGroup");
@@ -420,11 +420,16 @@ export class HaTracePathDetails extends LitElement {
return nothing;
}
const targetSpec = this._getTargetSelector(config, type);
// Triggers and conditions, including a condition action, don't expand
// the groups their targets name.
const expandGroup =
type !== "trigger" && type !== "condition" && !("condition" in config);
return html`<div class="targets">
<ha-automation-row-targets
.target=${target}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.size=${size}
.expandGroup=${expandGroup}
interactive
></ha-automation-row-targets>
</div>`;
+21 -1
View File
@@ -48,6 +48,8 @@ class HaUserPicker extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state() private _loadFailed = false;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
if (!this.users) {
@@ -56,7 +58,18 @@ class HaUserPicker extends LitElement {
}
private async _fetchUsers() {
this.users = await fetchUsers(this._api.callWS);
this._loadFailed = false;
try {
this.users = await fetchUsers(this._api.callWS);
} catch {
this._loadFailed = true;
}
}
private _retryFetchUsers(ev: Event) {
ev.stopPropagation();
this._fetchUsers();
}
private usersMap = memoizeOne((users?: User[]): Map<string, User> => {
@@ -142,9 +155,16 @@ class HaUserPicker extends LitElement {
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
.loading=${!this._loadFailed && this.users === undefined}
.loadError=${
this._loadFailed
? this._localize("ui.components.user-picker.load_failed")
: undefined
}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this._localize("ui.components.user-picker.unknown")}
@value-changed=${this._valueChanged}
@picker-retry=${this._retryFetchUsers}
>
</ha-generic-picker>
`;
+12 -8
View File
@@ -1,5 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import { getCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types";
@@ -27,12 +27,12 @@ export const fetchCategoryRegistry = (conn: Connection, scope: string) =>
categories.sort((ent1, ent2) => stringCompare(ent1.name, ent2.name))
);
export const subscribeCategoryRegistry = (
export const getCategoryRegistryCollection = (
conn: Connection,
scope: string,
onChange: (floors: CategoryRegistryEntry[]) => void
scope: string
) =>
createCollection<CategoryRegistryEntry[]>(
getCollection<CategoryRegistryEntry[]>(
conn,
`_categoryRegistry_${scope}`,
(conn2: Connection) => fetchCategoryRegistry(conn2, scope),
(conn2: Connection, store: Store<CategoryRegistryEntry[]>) =>
@@ -47,11 +47,15 @@ export const subscribeCategoryRegistry = (
true
),
"category_registry_updated"
),
conn,
onChange
)
);
export const subscribeCategoryRegistry = (
conn: Connection,
scope: string,
onChange: (floors: CategoryRegistryEntry[]) => void
) => getCategoryRegistryCollection(conn, scope).subscribe(onChange);
export const createCategoryRegistryEntry = (
callWS: HomeAssistant["callWS"],
scope: string,
+60 -1
View File
@@ -422,6 +422,7 @@ export interface EnergyData {
startCompare?: Date;
endCompare?: Date;
compareMode?: CompareMode;
combineSources?: boolean;
prefs: EnergyPreferences;
info: EnergyInfo;
stats: Statistics;
@@ -945,10 +946,13 @@ export interface EnergyCollection extends Omit<
start: Date;
end?: Date;
compare?: CompareMode;
combineSources?: boolean;
prefs?: EnergyPreferences;
clearPrefs(): void;
setPeriod(newStart: Date, newEnd?: Date): void;
setCompare(compare: CompareMode): void;
setCombineSources(combine: boolean): void;
setDefaultCombineSources(combine: boolean): void;
isActive(): boolean;
_refreshTimeout?: number;
_updatePeriodTimeout?: number;
@@ -1036,6 +1040,24 @@ export const getEnergyDefaultPeriodStorageKey = (
return `energy-default-period-${key}`;
};
export const getEnergyCombineSourcesStorageKey = (
panelUrl: string,
collectionKey?: string
): string => {
const [key] = convertCollectionKeyToConnection(panelUrl, collectionKey);
return `energy-combine-sources-${key}`;
};
export const hasCombinableEnergySources = (
prefs: EnergyPreferences,
types?: EnergySource["type"][]
): boolean => {
const sources = types
? prefs.energy_sources.filter((source) => types.includes(source.type))
: prefs.energy_sources;
return new Set(sources.map((source) => source.type)).size < sources.length;
};
// When today's first hourly statistic becomes available (01:00 in the
// configured timezone). Rolling the statistics view over at midnight would
// show an empty graph.
@@ -1180,6 +1202,11 @@ export const getEnergyDataCollection = (
: preferredPeriod;
const [initialStart, initialEnd] = calcDateRange(locale, config, period);
const combineSourcesStorageKey = getEnergyCombineSourcesStorageKey(
panelUrl,
options.key
);
const storedCombineSources = localStorage.getItem(combineSourcesStorageKey);
// True while tracking today (or hour-0 yesterday), rather than a custom range.
let followLiveDay = preferredPeriod === "today";
@@ -1234,7 +1261,7 @@ export const getEnergyDataCollection = (
return;
}
publishedSelectionRevision = requestSelectionRevision;
store.setState(data, true);
store.setState(withCombineSources(data), true);
};
// Use the collection's subscription lifecycle, but publish explicitly so an
@@ -1354,6 +1381,23 @@ export const getEnergyDataCollection = (
};
};
// The setting only applies while a source type is configured twice, so it
// cannot leave cards split once there is nothing left to combine.
const withCombineSources = (data: EnergyData): EnergyData => ({
...data,
combineSources: hasCombinableEnergySources(data.prefs)
? collection.combineSources
: undefined,
});
// Display-only: re-publish the current data instead of fetching it again.
const applyCombineSources = (combine: boolean) => {
collection.combineSources = combine;
if (store.state) {
store.setState(withCombineSources(store.state), true);
}
};
const collection: EnergyCollection = {
get state() {
return store.state;
@@ -1362,6 +1406,12 @@ export const getEnergyDataCollection = (
subscribe,
start: calcDate(initialStart, startOfDay, locale, config),
end: calcDate(initialEnd, endOfDay, locale, config),
combineSources:
storedCombineSources === "true"
? true
: storedCombineSources === "false"
? false
: undefined,
prefs: options.prefs,
_active: 0,
isActive: () => !!collection._active,
@@ -1398,6 +1448,15 @@ export const getEnergyDataCollection = (
collection.compare = compare;
}
},
setCombineSources(combine: boolean) {
applyCombineSources(combine);
localStorage.setItem(combineSourcesStorageKey, String(combine));
},
setDefaultCombineSources(combine: boolean) {
if (collection.combineSources === undefined) {
applyCombineSources(combine);
}
},
};
// Custom cards also access the Energy collection directly on the connection.
(connection as any)[key] = collection;
+31 -1
View File
@@ -46,8 +46,14 @@ const FAN_SPEEDS_NAMED: Partial<Record<number, FanSpeed[]>> = {
4: ["off", "low", "medium", "high"],
};
// Fans with up to this many speeds (off included) show buttons by default.
export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 6;
// Fans with more speeds than this (off included) can only show a slider.
export const FAN_SPEED_COUNT_MAX_FOR_DISCRETE_SPEEDS = 11;
export type FanSpeedStyle = "buttons" | "slider";
export function computeFanSpeedCount(stateObj: FanEntity): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
@@ -56,7 +62,7 @@ export function computeFanSpeedCount(stateObj: FanEntity): number {
export function computeFanSpeeds(stateObj: FanEntity): FanSpeed[] | undefined {
const speedCount = computeFanSpeedCount(stateObj);
if (speedCount > FAN_SPEED_COUNT_MAX_FOR_BUTTONS) {
if (speedCount > FAN_SPEED_COUNT_MAX_FOR_DISCRETE_SPEEDS) {
return undefined;
}
return (
@@ -70,6 +76,30 @@ export function computeFanSpeeds(stateObj: FanEntity): FanSpeed[] | undefined {
);
}
export const computeDefaultFanSpeedStyle = (
stateObj: FanEntity
): FanSpeedStyle =>
computeFanSpeedCount(stateObj) <= FAN_SPEED_COUNT_MAX_FOR_BUTTONS
? "buttons"
: "slider";
// A slider brings nothing to an off/on fan, so it always shows buttons.
export const supportsFanSpeedStyleChoice = (stateObj: FanEntity): boolean => {
const speedCount = computeFanSpeedCount(stateObj);
return (
speedCount >= 3 && speedCount <= FAN_SPEED_COUNT_MAX_FOR_DISCRETE_SPEEDS
);
};
// Fans without a choice always use their default style, whatever is requested.
export const resolveFanSpeedStyle = (
stateObj: FanEntity,
style?: FanSpeedStyle
): FanSpeedStyle =>
supportsFanSpeedStyleChoice(stateObj)
? (style ?? computeDefaultFanSpeedStyle(stateObj))
: computeDefaultFanSpeedStyle(stateObj);
export const isNumberedFanSpeed = (speed: FanSpeed): speed is `${number}` =>
!isNaN(Number(speed));
+30 -3
View File
@@ -13,7 +13,11 @@ import { computeRTL } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import { isNumericSensorDeviceClass } from "./sensor";
import type { FrontendLocaleData } from "./translation";
import type { Statistics } from "./recorder";
import {
isExternalStatistic,
type Statistics,
type StatisticsMetaData,
} from "./recorder";
const DOMAINS_USE_LAST_UPDATED = ["climate", "humidifier", "water_heater"];
const NEED_ATTRIBUTE_DOMAINS = [
@@ -517,11 +521,29 @@ const isNumericFromAttributes = (attributes: Record<string, any>) =>
const BLANK_UNIT = " ";
/**
* Attributes for an external statistic, which has no entity. Without them the
* history names the series after its ID and shows it without a unit.
*/
const statisticAttributes = (
metadata: StatisticsMetaData | undefined
): Record<string, string> => {
const attributes: Record<string, string> = {};
if (metadata?.name) {
attributes.friendly_name = metadata.name;
}
if (metadata?.statistics_unit_of_measurement) {
attributes.unit_of_measurement = metadata.statistics_unit_of_measurement;
}
return attributes;
};
export const convertStatisticsToHistory = (
hass: HomeAssistant,
statistics: Statistics,
statisticIds: string[],
splitDeviceClasses = false
splitDeviceClasses = false,
statisticsMetadata?: Record<string, StatisticsMetaData>
): HistoryResult => {
// Maintain the statistic id ordering
const orderedStatistics: Statistics = {};
@@ -534,10 +556,15 @@ export const convertStatisticsToHistory = (
// Convert statistics to HistoryResult format
const statsHistoryStates: HistoryStates = {};
Object.entries(orderedStatistics).forEach(([key, value]) => {
// Other statistics keep a blank unit, so that they merge into the history
// line of their entity even after the entity was removed.
const attributes = isExternalStatistic(key)
? statisticAttributes(statisticsMetadata?.[key])
: {};
const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({
s: e.mean != null ? e.mean.toString() : e.state!.toString(),
lc: e.end / 1000,
a: {},
a: attributes,
lu: e.end / 1000,
}));
statsHistoryStates[key] = entityHistoryStates;
+13
View File
@@ -34,6 +34,7 @@ export interface RepositoryBase {
export interface RepositoryInfo extends RepositoryBase {
additional_info: string;
beta: boolean;
default_branch: string;
issues: number;
releases: string[];
@@ -98,6 +99,18 @@ export const uninstallMarketplaceRepository = (
repository: repositoryId,
});
// The latest version and the updates include pre-releases when on
export const setMarketplaceRepositoryBeta = (
hass: CallWS,
repositoryId: string,
showBeta: boolean
) =>
hass.callWS<null>({
type: "marketplace/repository/beta",
repository: repositoryId,
show_beta: showBeta,
});
// Fetches the information of the repository from GitHub again
export const refreshMarketplaceRepository = (
hass: CallWS,
+1
View File
@@ -469,6 +469,7 @@ export interface ObjectSelector {
translation_key?: string;
fields?: Record<string, ObjectSelectorField>;
multiple?: boolean;
reorder?: boolean;
} | null;
}
+24 -1
View File
@@ -1,12 +1,16 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { ensureArray } from "../../common/array/ensure-array";
import { THEME_COLORS } from "../../common/color/compute-color";
import { createDurationData } from "../../common/datetime/create_duration_data";
import { formatDurationLong } from "../../common/datetime/format_duration";
import { normalizeDuration } from "../../common/datetime/normalize_duration";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { LocalizeFunc } from "../../common/translations/localize";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import type { HomeAssistantFormatters } from "../../types";
import type { AreaRegistryEntry } from "../area/area_registry";
import type { DeviceRegistryEntry } from "../device/device_registry";
@@ -150,6 +154,25 @@ export const formatSelectorValue = (
.join(", ");
}
if ("ui_color" in selector) {
const color = String(value);
const extraOption = selector.ui_color?.extra_options?.find(
(option) => option.value === color
);
if (extraOption) {
return extraOption.label;
}
if (color === "none" || color === "state") {
return options.localize(`ui.components.color-picker.${color}`);
}
if (THEME_COLORS.has(color)) {
return options.localize(
`ui.components.color-picker.colors.${color}` as LocalizeKeys
);
}
return color;
}
if ("duration" in selector) {
const data = createDurationData(value);
if (!data) {
+8
View File
@@ -3,6 +3,14 @@ import type { HomeAssistant } from "../types";
export type SystemLogLevel =
"critical" | "error" | "warning" | "info" | "debug";
// The levels Core's system log keeps, most severe first. It drops anything
// below warning.
export const SYSTEM_LOG_LEVELS = [
"critical",
"error",
"warning",
] as const satisfies readonly SystemLogLevel[];
export interface LoggedError {
name: string;
message: [string, ...string[]];
+67
View File
@@ -76,6 +76,73 @@ export const extractFromTarget = async (
);
};
export interface TargetItemEntities extends TargetItem {
entities: string[];
}
export interface TargetExcludeEffect extends TargetItem {
removed: TargetItemEntities[];
}
/**
* An included item with what it resolves to, and the areas and devices it
* reaches on the way, so the picker's filters can narrow it like its row.
*/
export interface TargetIncludeEntities extends TargetItemEntities {
referenced_areas: string[];
referenced_devices: string[];
}
export interface TargetExplanation {
includes: TargetIncludeEntities[];
excludes: TargetExcludeEffect[];
}
export const explainTarget = (
callWS: CallWS,
target: HassServiceTargetWithExclude,
expandGroup = true,
primaryEntitiesOnly = true
) => {
const request = {
type: "explain_target" as const,
target,
expand_group: expandGroup,
primary_entities_only: primaryEntitiesOnly,
};
return shareInFlightRequest(callWS, JSON.stringify(request), () =>
callWS<TargetExplanation>(request)
);
};
/**
* What each item of an explained target is left with once every exclude is
* taken out of it, by its `type:id`.
*/
export const targetEntitiesAfterExclude = (
explanation: TargetExplanation
): Record<string, string[]> => {
const removed: Record<string, Set<string>> = {};
explanation.excludes.forEach((exclude) =>
exclude.removed.forEach(({ type, id, entities }) => {
const key = `${type}:${id}`;
removed[key] ??= new Set();
entities.forEach((entityId) => removed[key].add(entityId));
})
);
return Object.fromEntries(
explanation.includes.map(({ type, id, entities }) => {
const key = `${type}:${id}`;
const out = removed[key];
return [
key,
out ? entities.filter((entityId) => !out.has(entityId)) : entities,
];
})
);
};
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
const tempTarget = {
entity_id: target?.entity_id ? ensureArray(target?.entity_id) : [],
+22 -13
View File
@@ -3,7 +3,8 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-icon-next";
import "../../components/ha-list-item";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
import type { DataEntryFlowStepMenu } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
@@ -79,28 +80,30 @@ class StepFlowMenu extends LitElement {
return html`
${description ? html`<div class="content">${description}</div>` : nothing}
<div class="options">
<ha-list-base
class="options"
aria-label=${this.flowConfig.renderMenuHeader(this.hass, this.step)}
>
${options.map((option) => {
const optionDescription = optionDescriptions[option];
return html`
<ha-list-item
hasMeta
<ha-list-item-button
.headline=${translations[option]}
.step=${option}
@click=${this._handleStep}
?twoline=${optionDescription}
?multiline-secondary=${optionDescription}
>
<span>${translations[option]}</span>
${
optionDescription
? html`<span slot="secondary"> ${optionDescription} </span>`
? html`<span slot="supporting-text" class="description"
>${optionDescription}</span
>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
})}
</div>
</ha-list-base>
`;
}
@@ -129,8 +132,14 @@ class StepFlowMenu extends LitElement {
.content + .options {
margin-top: var(--ha-space-2);
}
ha-list-item {
--mdc-list-side-padding: var(--ha-space-6);
ha-list-item-button {
--ha-row-item-padding-inline: var(--ha-space-6);
}
.description {
white-space: normal;
}
ha-icon-next {
color: var(--secondary-text-color);
}
`,
];
@@ -1,19 +1,11 @@
import type { ContextType } from "@lit/context";
import "cropperjs";
import type {
CropperCanvas,
CropperGrid,
CropperHandle,
CropperImage,
CropperSelection,
CropperShade,
} from "cropperjs";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import Cropper from "cropperjs";
// @ts-ignore
import cropperCss from "cropperjs/dist/cropper.css";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../common/decorators/consume";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
@@ -24,74 +16,22 @@ import { haStyleDialog } from "../../resources/styles";
import { DialogMixin } from "../dialog-mixin";
import type { HaImageCropperDialogParams } from "./show-image-cropper-dialog";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
const MIN_SELECTION_SIZE = 50;
const INITIAL_COVERAGE = 0.8;
const EPSILON = 0.05;
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const contains = (outer: Rect, inner: Rect): boolean =>
inner.x >= outer.x - EPSILON &&
inner.y >= outer.y - EPSILON &&
inner.x + inner.width <= outer.x + outer.width + EPSILON &&
inner.y + inner.height <= outer.y + outer.height + EPSILON;
const intersect = (a: Rect, b: Rect): Rect => {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
return {
x,
y,
width: Math.min(a.x + a.width, b.x + b.width) - x,
height: Math.min(a.y + a.height, b.y + b.height) - y,
};
};
const scaleRect = ({ x, y, width, height }: Rect, ratio: number): Rect => ({
x: x * ratio,
y: y * ratio,
width: width * ratio,
height: height * ratio,
});
@customElement("image-cropper-dialog")
export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>()(
DialogMixin<HaImageCropperDialogParams>(LitElement)
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@query("cropper-image") private _image?: CropperImage;
@query("img") private _image!: HTMLImageElement;
@query("cropper-selection") private _selection?: CropperSelection;
@state() private _imageSize?: { width: number; height: number };
@state() private _shown = false;
private _cropper?: Cropper;
@state() private _cropReady = false;
@state() private _isTargetAspectRatio?: boolean;
private _imageUrl?: string;
private _resizeObserver?: ResizeObserver;
private _canvasWidth?: number;
private _refitting = false;
private _cropped = false;
private _closing = false;
@@ -101,176 +41,51 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
if (!this._cropped) {
this.params?.cancelCallback?.();
}
if (this._imageUrl) {
URL.revokeObjectURL(this._imageUrl);
this._imageUrl = undefined;
}
this._resizeObserver?.disconnect();
this._resizeObserver = undefined;
this._cropper?.destroy();
this._cropper = undefined;
}
protected firstUpdated() {
if (!this.params) {
return;
}
this._imageUrl = URL.createObjectURL(this.params.file);
this._loadImageSize(this._imageUrl);
}
private async _loadImageSize(url: string) {
const image = new Image();
image.src = url;
try {
await image.decode();
} catch {
return;
}
if (!this.isConnected) {
return;
}
this._imageSize = {
width: image.naturalWidth,
height: image.naturalHeight,
};
this._isTargetAspectRatio = this._checkMatchAspectRatio(
image.naturalWidth,
image.naturalHeight
);
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
!this._cropReady &&
(changedProps.has("_imageSize") || changedProps.has("_shown")) &&
this._image
) {
this._initCropper(this._image);
}
}
private async _initCropper(image: CropperImage) {
try {
await image.$ready();
} catch {
return;
}
if (!this.isConnected || image !== this._image) {
return;
}
const selection = this._selection!;
if (!selection.width) {
await new Promise<void>((resolve) => {
selection.addEventListener("change", () => resolve(), { once: true });
});
if (!this.isConnected || image !== this._image) {
return;
}
}
this._initDirtyTracking(
{ type: "deep" },
this._cropData(this._selectionRect(), this._imageRect())
);
this._cropReady = true;
this._resizeObserver = new ResizeObserver((entries) => {
this._refit(entries[0].contentRect);
this._image.src = URL.createObjectURL(this.params.file);
this._cropper = new Cropper(this._image, {
aspectRatio: this.params.options.aspectRatio,
viewMode: 1,
dragMode: "move",
minCropBoxWidth: 50,
ready: () => {
this._isTargetAspectRatio = this._checkMatchAspectRatio();
URL.revokeObjectURL(this._image!.src);
this._initDirtyTracking({ type: "deep" }, this._cropper!.getData());
this._cropReady = true;
},
crop: () => {
if (this._cropReady) {
this._updateDirtyState(this._cropper!.getData());
}
},
});
this._resizeObserver.observe(image.parentElement!);
}
private _selectionRect(): Rect {
const { x, y, width, height } = this._selection!;
return { x, y, width, height };
}
private _canvasRect(): Rect {
const { width, height } =
this._image!.parentElement!.getBoundingClientRect();
return { x: 0, y: 0, width, height };
}
private _imageRect(): Rect {
const canvasRect = this._image!.parentElement!.getBoundingClientRect();
const imageRect = this._image!.getBoundingClientRect();
return {
x: imageRect.x - canvasRect.x,
y: imageRect.y - canvasRect.y,
width: imageRect.width,
height: imageRect.height,
};
}
private _cropData(selection: Rect, image: Rect): Rect {
const scale = image.width / this._imageSize!.width;
return {
x: Math.round((selection.x - image.x) / scale),
y: Math.round((selection.y - image.y) / scale),
width: Math.round(selection.width / scale),
height: Math.round(selection.height / scale),
};
}
private _refit(canvas: { width: number; height: number }) {
const previous = this._canvasWidth;
this._canvasWidth = canvas.width;
if (!previous || !canvas.width || previous === canvas.width) {
return;
}
const current = this._selectionRect();
const ratio = Math.min(
canvas.width / previous,
canvas.height / current.height
);
const image = scaleRect(this._imageRect(), ratio);
const selection = scaleRect(current, ratio);
const dy =
image.height <= canvas.height
? (canvas.height - image.height) / 2 - image.y
: clamp(
0,
-selection.y,
canvas.height - selection.y - selection.height
);
image.y += dy;
selection.y += dy;
const { width, height } = this._imageSize!;
this._refitting = true;
this._image!.$setTransform(
image.width / width,
0,
0,
image.height / height,
image.x + image.width / 2 - width / 2,
image.y + image.height / 2 - height / 2
);
this._selection!.$change(
selection.x,
selection.y,
selection.width,
selection.height
);
this._refitting = false;
}
private _checkMatchAspectRatio(
naturalWidth: number,
naturalHeight: number
): boolean {
private _checkMatchAspectRatio(): boolean {
const targetRatio = this.params?.options.aspectRatio;
if (!targetRatio) {
return true;
}
if (naturalWidth / naturalHeight === targetRatio) {
const imageData = this._cropper!.getImageData();
if (imageData.aspectRatio === targetRatio) {
return true;
}
// If the image is not exactly the aspect ratio see if it is within a pixel.
if (naturalWidth > naturalHeight) {
const targetHeight = naturalWidth / targetRatio;
return Math.abs(targetHeight - naturalHeight) <= 1;
if (imageData.naturalWidth > imageData.naturalHeight) {
const targetHeight = imageData.naturalWidth / targetRatio;
return Math.abs(targetHeight - imageData.naturalHeight) <= 1;
}
const targetWidth = naturalHeight * targetRatio;
return Math.abs(targetWidth - naturalWidth) <= 1;
const targetWidth = imageData.naturalHeight * targetRatio;
return Math.abs(targetWidth - imageData.naturalWidth) <= 1;
}
protected render(): TemplateResult | typeof nothing {
@@ -278,65 +93,23 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
return nothing;
}
const label = this._i18n.localize("ui.dialogs.image_cropper.crop_image");
return html`
<ha-dialog
open
header-title=${label}
header-title=${this._i18n.localize(
"ui.dialogs.image_cropper.crop_image"
)}
.preventScrimClose=${this.isDirtyState}
@wa-hide=${this._handleHide}
@after-show=${this._handleAfterShow}
>
<div
class="container ${classMap({
round: Boolean(this.params.options.round),
})}"
>
${
this._imageSize && this._shown
? html`
<cropper-canvas
background
style=${styleMap({
aspectRatio: `${this._imageSize.width} / ${this._imageSize.height}`,
})}
>
<cropper-image
src=${this._imageUrl!}
alt=${label}
scalable
translatable
@change=${this._handleImageChange}
></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="move" plain></cropper-handle>
<cropper-selection
aspect-ratio=${ifDefined(
this.params.options.aspectRatio || undefined
)}
initial-coverage=${INITIAL_COVERAGE}
precise
movable
resizable
outlined
@change=${this._handleSelectionChange}
>
<cropper-grid role="grid" bordered covered></cropper-grid>
<cropper-handle action="move" plain></cropper-handle>
<cropper-handle action="n-resize"></cropper-handle>
<cropper-handle action="e-resize"></cropper-handle>
<cropper-handle action="s-resize"></cropper-handle>
<cropper-handle action="w-resize"></cropper-handle>
<cropper-handle action="ne-resize"></cropper-handle>
<cropper-handle action="nw-resize"></cropper-handle>
<cropper-handle action="se-resize"></cropper-handle>
<cropper-handle action="sw-resize"></cropper-handle>
</cropper-selection>
</cropper-canvas>
`
: nothing
}
<img
alt=${this._i18n.localize("ui.dialogs.image_cropper.crop_image")}
/>
</div>
<ha-dialog-footer slot="footer">
<ha-button
@@ -354,7 +127,9 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
appearance="plain"
@click=${this._useOriginal}
>
${this._i18n.localize("ui.dialogs.image_cropper.use_original")}
${this._i18n.localize(
"ui.dialogs.image_cropper.use_original"
)}
</ha-button>
`
: nothing
@@ -371,68 +146,17 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
`;
}
private _handleAfterShow() {
this._shown = true;
}
private _handleHide(ev: Event) {
// Ignore hide events from nested overlays and blocked dismissals.
if (ev.eventPhase !== Event.AT_TARGET || ev.defaultPrevented) {
return;
}
this._closing = true;
}
private _handleSelectionChange(ev: CustomEvent<Rect>) {
if (!this._cropReady || this._refitting) {
return;
}
const selection = this._selection!;
const image = this._imageRect();
const next = ev.detail;
if (
(next.width < MIN_SELECTION_SIZE && next.width < selection.width) ||
!contains(intersect(image, this._canvasRect()), next)
) {
ev.preventDefault();
return;
}
this._updateDirtyState(this._cropData(next, image));
}
private _handleImageChange(ev: CustomEvent<Rect>) {
if (!this._cropReady || this._refitting) {
return;
}
const selection = this._selectionRect();
if (!contains(ev.detail, selection)) {
ev.preventDefault();
return;
}
this._updateDirtyState(this._cropData(selection, ev.detail));
}
private async _cropImage() {
const selection = this._selection;
if (!selection || !this._cropReady) {
return;
}
const image = this._imageRect();
const rect = this._selectionRect();
const x = clamp(rect.x, image.x, image.x + image.width - rect.width);
const y = clamp(rect.y, image.y, image.y + image.height - rect.height);
if (x !== rect.x || y !== rect.y) {
selection.$change(x, y);
}
const crop = this._cropData(this._selectionRect(), image);
let canvas: HTMLCanvasElement;
try {
canvas = await selection.$toCanvas({
width: crop.width,
beforeDraw: (_context, target) => {
target.height = crop.height;
},
});
} catch {
private _cropImage() {
const canvas = this._cropper?.getCroppedCanvas();
if (!canvas) {
return;
}
const params = this.params!;
@@ -464,22 +188,22 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
return [
haStyleDialog,
css`
${unsafeCSS(cropperCss)}
.container {
max-width: 640px;
}
cropper-canvas {
width: 100%;
direction: ltr;
--theme-color: var(--primary-color);
img {
max-width: 100%;
}
cropper-handle {
--theme-color: var(--primary-color);
}
.container.round cropper-selection,
.container.round cropper-shade,
.container.round cropper-grid {
.container.round .cropper-view-box,
.container.round .cropper-face {
border-radius: var(--ha-picture-border-radius);
}
.cropper-line,
.cropper-point,
.cropper-point.point-se::before {
background-color: var(--primary-color);
}
`,
];
}
@@ -488,11 +212,5 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
declare global {
interface HTMLElementTagNameMap {
"image-cropper-dialog": HaImagecropperDialog;
"cropper-canvas": CropperCanvas;
"cropper-image": CropperImage;
"cropper-shade": CropperShade;
"cropper-handle": CropperHandle;
"cropper-selection": CropperSelection;
"cropper-grid": CropperGrid;
}
}
+1 -10
View File
@@ -24,7 +24,6 @@ import { customElement, property, query, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
@@ -37,7 +36,6 @@ import {
computeEntityNameParts,
type EntityNameType,
} from "../../common/entity/compute_entity_name_display";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import {
getHistoryState,
navigate,
@@ -561,14 +559,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
await favoritesHandler.copy(favoritesContext);
}
private _goToAddEntityTo(ev: CustomEvent<RequestSelectedDetail>) {
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (
ev.type === "request-selected" &&
!shouldHandleRequestSelectedEvent(ev)
) {
return;
}
private _goToAddEntityTo() {
this._setView("add_to");
}
+11 -1
View File
@@ -122,7 +122,13 @@ export class MockBaseEntity {
this.attributes = { ...this.attributes, ...changes.attributes };
}
this.hass!.updateStates({
// A removed or replaced entity can finish its transitions, so that their
// timers stop, but must not bring its state back
if (this.hass?.mockEntities[this.entityId] !== this) {
return;
}
this.hass.updateStates({
[this.entityId]: this.toState(),
});
}
@@ -159,7 +165,11 @@ export class MockBaseEntity {
return {
entity_id: this.entityId,
state: this.state,
// Every attribute of the input, like Core keeps the attributes of an
// integration. What the mock works out itself wins, like a light that
// is off having no brightness.
attributes: {
...this.attributes,
...this._getBaseAttributes(),
...this._getCapabilityAttributes(),
...this._getStateAttributes(),
+12
View File
@@ -0,0 +1,12 @@
import { MockBaseEntity } from "./base-entity";
import type { EntityAttributes } from "./types";
/**
* Entity for domains without their own mock, such as sensor, weather and
* calendar. Keeps all its attributes, as nothing here computes them.
*/
export class MockGenericEntity extends MockBaseEntity {
protected _getStateAttributes(): EntityAttributes {
return this.attributes;
}
}
+4 -2
View File
@@ -1,10 +1,11 @@
import { MockBaseEntity } from "./base-entity";
import type { MockBaseEntity } from "./base-entity";
import type { EntityInput } from "./types";
import { MockAlarmControlPanelEntity } from "./alarm-control-panel-entity";
import { MockAutomationEntity } from "./automation-entity";
import { MockClimateEntity } from "./climate-entity";
import { MockCoverEntity } from "./cover-entity";
import { MockFanEntity } from "./fan-entity";
import { MockGenericEntity } from "./generic-entity";
import { MockGroupEntity } from "./group-entity";
import { MockHumidifierEntity } from "./humidifier-entity";
import { MockInputNumberEntity } from "./input-number-entity";
@@ -49,5 +50,6 @@ const TYPES: Record<string, EntityConstructor> = {
export const getEntity = (input: EntityInput): MockBaseEntity => {
const [domain] = input.entity_id.split(".", 2);
return new (TYPES[domain] || MockBaseEntity)(input);
return new (TYPES[domain] || MockGenericEntity)(input);
};
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff. Show more