Compare commits

...
59 Commits
Author SHA1 Message Date
Bram Kragten e32bc0696e Bumped version to 20261028.5 2026-11-06 15:14:00 +01:00
WendelinandClaude Opus 5.5 6327304f4d Fix bright card outlines on Shelly Wall Display (#55704)
* Keep rgba() colors in the legacy CSS build

Raising the legacy floor to Chrome 67 lets lightningcss rewrite rgba()
to 8-digit hex (#rrggbbaa), since Chrome 62 supports it. Kiosk webviews
like the Shelly Wall Display get the legacy build but can't parse that,
so var(--divider-color) borders on cards, toggles and buttons fell back
to currentColor and showed as bright outlines, the same symptom as
#52986. Always compile hex alpha colors to rgba() in legacy builds, for
both css`` templates and imported stylesheets. Modern builds are
unchanged.

Also drop the loader's redundant /* global */ header, which ESLint now
rejects because module and require are already built-in globals in
.cjs files.

Fixes #55699

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

* Also keep rgba() colors in the legacy service worker build

The template loader runs with the legacy-sw browserslist env for the
service worker layer, which an exact match on legacy missed.

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

---------

Co-authored-by: Claude <[email protected]>
2026-11-06 15:12:17 +01:00
Petar Petrov 11c79e09e9 Fit narrow chart legends to three rows instead of two (#55697) 2026-11-06 15:12:16 +01:00
Paul Bottein 5060b37381 Fix truncated buttons in the voice assistant setup (#55677) 2026-11-06 15:12:14 +01:00
Aidan Timson 63e3a04f15 Fix the max-age of the demo and cast cache headers (#55676) 2026-11-06 15:12:13 +01:00
Aidan Timson 818de1da8e Render full dashboards with their tabs in the demo embed (#55640)
* Add an embedded mode to hui-root that leaves the page's URL alone

* Add a dashboard component to the demo embed

* Keep embedded dashboards' back navigation, height and sidebar tabs inside the embed

* Follow anchors, replacements and new configs in embedded dashboards

* Go back past a followed back_path in embedded dashboards

* Isolate embedded view containers so view backgrounds show

* Match decoded and aliased views and skip empty embedded dashboards

* Read the sidebar tab once sections views have their dashboard

* Declare the navigate events in HTMLElementEventMap

* Skip the Lovelace info fetch and its mock in embedded dashboards
2026-11-06 15:12:11 +01:00
Aidan Timson 70897feab1 Render views and dashboard strategies in the demo embed (#55639)
* Add a view component to the demo embed

* Show only the latest generated dashboard in the demo embed

* Stop stale and repeated strategy results in embedded views

* Draw view themes and backgrounds in embedded views
2026-11-06 15:12:10 +01:00
Aidan Timson f827c09e72 Let embed entities set their entity registry options (#55675) 2026-11-06 15:09:48 +01:00
Aidan Timson a4bb97b71c Render sections in the demo embed, with a device registry (#55638)
* Share embed provider lookup and theme in a base element

* Add devices to the demo embed provider

* Add a section component to the demo embed

* Draw section backgrounds and accept more section configs in the demo embed
2026-11-06 15:09:43 +01:00
puddly 509561aabe Allow the ESPHome serial proxy URL to be null (#55665)
* Allow the ESPHome serial proxy URL to be None

* Clarify comment
2026-11-06 14:58:14 +01:00
Christian Wiedel 0446eb5a62 Fix wrapped flow dialog titles overlapping the subtitle (#55661) 2026-11-06 14:58:13 +01:00
Paul Bottein e815c44a42 Fix area controls freezing large dashboards (#55650) 2026-11-06 14:58:11 +01:00
Joost LekkerkerkerandClaude Opus 5.5 4551a87a7a Label marketplace open issue count as issues and pull requests (#55635)
* Label marketplace open issue count as issues and pull requests

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

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

* Update marketplace tests for the renamed open issues keys

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-06 14:58:10 +01:00
Alexander Hass 23e376c7b4 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-06 14:58:09 +01:00
Bram Kragten a67b691a45 Bumped version to 20261028.4 2026-11-04 12:05:25 +01:00
Franck Nijhof fc0f86e46d Add Marketplace download source menu item for the China mirrors (#55615) 2026-11-04 12:04:21 +01:00
Aidan Timson ef6438a77d Fix the demo embed for the contributors site (#55596) 2026-11-04 12:03:30 +01:00
WendelinandClaude Opus 5.5 7b4981e850 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 11:53:19 +01:00
Maarten Lakerveld a46e90de67 Stop recomputing active system domains on every state change in the add element dialog (#55586) 2026-11-04 11:31:19 +01:00
3dfadb7fdf 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:25:33 +01:00
Paul Bottein 6f3ef16d20 Add an option to include pre-releases in Marketplace repositories (#55567) 2026-11-04 11:01:42 +01:00
17d08d26f6 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 14:34:40 +01:00
Alexander Hass aa06e8ce25 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 14:34:39 +01:00
Bram Kragten 122877cdf5 Bumped version to 20261028.3 2026-11-02 16:42:17 +01:00
Josef Zweck a103eac243 Add access risk to the Marketplace warning (#55559) 2026-11-02 16:41:33 +01:00
Paul Bottein a9a8a9aad9 Show the area first in integration device rows (#55557) 2026-11-02 16:41:32 +01:00
WendelinandClaude Opus 5.5 35a054c1ba Fix picker rows missing the first click after scrolling (#55556)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-02 16:41:31 +01:00
960e99ed66 Refactor bulk delete labels (#55555)
Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-02 16:41:29 +01:00
Paul Bottein 232375befb Limit the more info activity to the 100 most recent entries (#55553) 2026-11-02 16:41:28 +01:00
Aidan Timson 91ee29a2e6 Use generic picker for install another version (#55552) 2026-11-02 16:41:26 +01:00
Aidan Timson a6dc5d0008 Shorten the Marketplace description on the settings page (#55549) 2026-11-02 16:41:25 +01:00
Thomas Mueller 7a66de362e Seek on slider change in the media control card (#55542)
* Seek on slider change in the media control card

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

* Remove media control card rendering test

The frontend testing guidance asks for tests of code that computes
something, not rendering tests.
2026-11-02 16:41:23 +01:00
Thomas Mueller 36500e5b19 Fix media progress slider on Chromium < 125 (#55539)
matches(":state(dragging)") throws before Chromium 125. Use
customStates.has("dragging") instead.
2026-11-02 16:41:22 +01:00
karwosts df88136147 Fix date-only attribute formatting to not shift into other days (#55536) 2026-11-02 16:41:21 +01:00
jimjaeger 2f1d738c2c Fix onboarding using US customary units when the location is detected (#55535)
Keep the detected unit system in onboarding
2026-11-02 16:41:19 +01:00
Paulus SchoutsenandClaude bfeb26b47d Link the MCP integration pages to the AI settings (#55515)
The AI settings show how to connect to the MCP server and the URL of
each of its APIs. The MCP client and MCP server integration pages now
have an "AI settings" button that opens the MCP card on that page. The
button is the primary action when the page has no "Add entry" button.

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


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

Co-authored-by: Claude <[email protected]>
2026-11-02 16:41:18 +01:00
Joost LekkerkerkerandClaude 8de5bfee89 Stretch chip container to fill host so outline matches host shape (#55510)
Co-authored-by: Claude <[email protected]>
2026-11-02 16:41:17 +01:00
Jan-Philipp BeneckeandCopilot Autofix powered by AI 1efc7f8a95 Fix network graph button spacing (#55501)
* Fix zoom reset button spacing

* Replace margin-right with margin-inline-end

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

* Align with chart-base

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-02 16:41:15 +01:00
Erwin DounaandAidan Timson 9e0d38fba8 Show a skeleton while the live log loads (#55443)
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 16:41:13 +01:00
2932f8f528 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 16:41:12 +01:00
Bram Kragten 6a0902d18c Bumped version to 20261028.2 2026-10-30 23:57:41 +01:00
Paul Bottein 4424f98eb6 Add translation for the Marketplace panel title (#55486) 2026-10-30 23:57:29 +01:00
Maarten Lakerveld ea13594fb0 Move the panel background stacking context off partial-panel-resolver (#55485)
Since #54801 the resolver creates a stacking context (isolation: isolate) so
its fixed ::before background sits behind the panel. The resolver is rendered
conditionally, once the startup data is available, and isolating it can
intermittently leave the Android WebView accessibility tree (Chromium
109-124) with an empty panel subtree while the page paints normally. UI
automation then never sees the dashboard; the companion app nightly E2E has
hit this on the legacy build (API 32/33/35) since the change landed.

Move the isolation to home-assistant-main's host, which exists before the
resolver is rendered. The pseudo-element and the Lovelace view background
both stay at z-index -1 in that context, in the same order as before, so the
themed background behaves exactly as #54801 intended. Measured with Maestro
on a Chrome 109 emulator: 0 stale trees in 62 cold loads, against 11 in 100
with the isolation on the resolver.
2026-10-30 23:57:28 +01:00
Daan Vervacke ce726a8a3f Wrap long title in YAML-only integration dialog (#55481) 2026-10-30 23:57:26 +01:00
Paul Bottein d67479da6d Replace hide-zoom-controls with a zoom slider mode on charts (#55468) 2026-10-30 23:57:25 +01:00
Bram Kragten 275e30879c Bumped version to 20261028.1 2026-10-29 16:44:20 +01:00
Maarten Lakerveld 7b53005865 Load the legacy Intl polyfills once per page and bundle PluralRules with its data (#55452)
Each legacy entry bundle carries its own copy of intl-polyfill.ts, so the
module-level guard only stopped re-runs within one bundle. With the bundles
loading in parallel, the second bundle could probe Intl.PluralRules after the
first had installed the polyfill but before its locale data chunk arrived. The
FormatJS should-polyfill probe calls select(), which throws on a data-less
polyfill, rejecting the top-level await so the entry never evaluated. On the
companion app this surfaces as ExternalBusTimeout on old WebViews.

Share one promise per page on globalThis so every bundle waits for the same
run, and import the PluralRules polyfill and its English data from one module
so Intl.PluralRules is never installed without data.
2026-10-29 16:43:58 +01:00
Paul Bottein 94950fcacd Show a toast with a link to the dashboard after adding to it (#55444) 2026-10-29 16:43:56 +01:00
Petar Petrov 4214fe46c6 Name new disk mounts after the partition's volume label (#55441) 2026-10-29 16:43:55 +01:00
Aidan Timson d6325eef7d Fix the Marketplace search field overlapping the top bar icons on narrow screens (#55439)
Let the narrow data table search field shrink to fit
2026-10-29 16:43:54 +01:00
WendelinandClaude Opus 5.5 12e722f122 Wrap long text in debug tool and network discovery rows (#55438)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-29 16:43:53 +01:00
Paul Bottein 83eb3ca13a Use the state color by default in alert cards and badges (#55436) 2026-10-29 16:43:51 +01:00
Petar Petrov 0532b1f5f6 Ignore YAML dashboard update event on first load (#55430) 2026-10-29 16:43:50 +01:00
Tristan Cartledge 970afb588d Fix duplicated hidden areas when reordering areas in the areas dashboard editor (#55424) 2026-10-29 16:43:49 +01:00
Maarten Lakerveld 23137ce6d2 Name maintenance problem tiles after the device and the entity (#55420)
Problem binary sensors were added to the maintenance view using the
battery tile builder, which names every tile after its device. A device
with several problem sensors (overheating, overvoltage, ...) then showed
identical tiles. Problem tiles now use a device plus entity name; the
entity part is dropped automatically when the sensor has no name of its
own, so single-sensor devices still read as just the device name.
Battery tiles keep the device-only name.

Fixes home-assistant/frontend#55413
2026-10-29 16:43:48 +01:00
JonathanandXyolyp 6b022d63e8 Preselect the only allowed unit also in an optional unit selector (#55417)
Co-authored-by: Xyolyp <[email protected]>
2026-10-29 16:43:46 +01:00
Paul Bottein d3c6e132b7 Show multi-select chips in a field with an inline add button (#55416) 2026-10-29 16:43:45 +01:00
Paul Bottein c08b261d4b Keep the section row divider under the entities card header (#55415) 2026-10-29 16:43:44 +01:00
Bram KragtenandClaude Opus 5.5 0721b22036 Keep the device and area page headers fixed while scrolling (#55409)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 16:43:43 +01:00
158 changed files with 6112 additions and 1243 deletions

No files matched your search

+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",
@@ -1,4 +1,3 @@
/* global module, require */
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
// tagged template literals using `minify-literals` (html-minifier-next +
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
return {
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
lightningcssTargets,
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
// build (e.g. Shelly Wall Display) can't parse that, so any
// var(--divider-color) border falls back to currentColor. Always
// compile hex alpha colors to rgba() in legacy builds.
lightningcssInclude: browserslistEnv.startsWith("legacy")
? lightningcssModule.Features.HexAlphaColors
: 0,
};
})
);
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
include: lightningcssInclude,
},
})
)
+21 -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,
},
},
{
@@ -242,11 +244,22 @@ const createRspackConfig = ({
path: paths.root_dir,
env: "legacy",
}),
// Keep rgba() instead of #rrggbbaa, see
// minify-template-literals-loader.cjs
include: { hexAlphaColors: true },
},
},
],
},
],
// 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: [
+5 -5
View File
@@ -1,5 +1,5 @@
/*
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
Referrer-Policy: no-referrer-when-downgrade
@@ -8,13 +8,13 @@
X-XSS-Protection: 1; mode=block
/images/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/manifest.json
Cache-Control: public, max-age: 3600, s-maxage=3600
Cache-Control: public, max-age=3600, s-maxage=3600
/frontend_es5/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
+12 -5
View File
@@ -1,18 +1,25 @@
/*
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Referrer-Policy: no-referrer-when-downgrade
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
/api/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/assets/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_es5/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
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");
+173
View File
@@ -0,0 +1,173 @@
import { ContextEvent } from "@lit/context";
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import type { ScriptObject } from "./script-data";
import { readScriptObject } from "./script-data";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so an element 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;
};
/**
* The base of the embed elements that render dashboard content. The element
* 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 element adds an empty one.
*/
export abstract class EmbedElement<
Config extends ScriptObject,
> extends LitElement {
/**
* The configuration of the content, the same as in a dashboard. Also read
* from a JSON or YAML script child, see `readScriptObject`.
*/
@property({ type: Object }) public config?: Config;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
protected _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// element and its content. Context requests only reach ancestors by
// themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
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 [origin] = ev.composedPath();
const node = origin instanceof HTMLElement ? origin : this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
// The page's address belongs to the page, so only a hash on it, such as
// an anchor, navigates there. The browser does that itself, as the app's
// navigate() doesn't scroll to the anchor.
this.addEventListener("hass-navigate", (ev) => {
const { path, replace } = ev.detail;
ev.preventDefault();
if (!path.startsWith("#")) {
this.navigateTo(path, replace);
} else if (replace) {
window.location.replace(path);
} else {
window.location.hash = path;
}
});
}
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 element keeps a config set since
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptObject(this);
if (data !== undefined) {
this.setScriptData(data);
}
}
/**
* A navigate action of the content, other than to a hash on the page. It
* leaves the page where it is, like actions that open a dialog. `replace`
* is the action's `navigation_replace`.
*/
protected navigateTo(_path: string, _replace?: boolean) {
// Nothing to navigate to, outside a dashboard
}
/** Whether the data of a script child is a configuration of the content. */
protected abstract isConfig(data: ScriptObject): data is Config;
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
protected setScriptData(data: ScriptObject) {
if (this.isConfig(data)) {
this.config = data;
}
}
/** The content, or `undefined` while there is nothing to render. */
protected abstract renderContent(
hass: HomeAssistant
): TemplateResult | undefined;
// The content in the theme of the provider
protected render() {
const hass = this._hass;
const content = hass && this.renderContent(hass);
if (!hass || !content) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
dir=${computeRTLDirection(hass)}
>
${content}
</div>`;
}
}
+28
View File
@@ -0,0 +1,28 @@
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";
import "./ha-embed-section";
import "./ha-embed-view";
import "./ha-embed-dashboard";
// 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");
+99
View File
@@ -0,0 +1,99 @@
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
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 { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
*
* 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 EmbedElement<LovelaceCardConfig> {
private _card = createRef<HuiCard>();
// A card names its type
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
return "type" in data && typeof data.type === "string";
}
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
return html`<hui-card
${ref(this._card)}
.hass=${hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>`;
}
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 = [
embedElementStyles,
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([fit-rows]) .theme {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
--row-gap
)
);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-card": HaEmbedCard;
}
}
+173
View File
@@ -0,0 +1,173 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import "../../../src/panels/lovelace/hui-root";
import type { HomeAssistant } from "../../../src/types";
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
* view in `view`, so switching tabs and following links to its views don't
* change the page's URL. The dashboard scrolls inside the element, under its
* toolbar.
*/
@customElement("ha-embed-dashboard")
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
private _scroller = createRef<HTMLDivElement>();
// The route of hui-root, here without a page URL around it. A new
// configuration gives a new route, as hui-root only looks up the view of
// the path again when its route changes, and the views may have moved.
private _route = memoizeOne(
(view: string | undefined, _config: LovelaceConfig) => ({
prefix: "",
path: `/${view ?? ""}`,
})
);
protected get rawConfig() {
return this.config;
}
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
return isDashboardConfig(data);
}
// The view shown, and the views before it, which the back button of a
// subview without a `back_path` goes back through, as the browser's back
// would in the app
private _shownView?: string;
private _history: (string | undefined)[] = [];
// Going back, or a navigate action with `navigation_replace`, which
// replaces the view shown rather than adding to the views before it
private _skipHistory = false;
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (this.view === this._shownView) {
return;
}
if (this._shownView !== undefined) {
if (!this._skipHistory) {
this._history.push(this._shownView);
}
this._scroller.value?.scrollTo({ top: 0 });
}
this._skipHistory = false;
this._shownView = this.view;
}
protected navigateTo(path: string, replace?: boolean) {
const view = this.viewOfPath(path);
if (view === undefined || this.isSameView(view, this.view)) {
return;
}
this._skipHistory = Boolean(replace);
this.view = view;
}
// The embedded attribute is in the template, so that hui-root has it when
// it connects and skips the listeners on the page
protected renderContent(hass: HomeAssistant) {
if (!this._lovelace?.config.views.length) {
return undefined;
}
return html`<div class="scroller" ${ref(this._scroller)}>
<hui-root
embedded
no-edit
.hass=${hass}
.lovelace=${this._lovelace}
.route=${this._route(this.view, this._lovelace.config)}
.narrow=${this._narrow.value ?? false}
@view-navigate=${this._viewNavigate}
></hui-root>
</div>`;
}
// A tab, or the back button of a subview, which goes to its `back_path`
// when that is one of the dashboard's views, else to the view before it
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
const view = this.viewOfPath(ev.detail.path);
if (!ev.detail.back) {
if (view !== undefined) {
this.view = view;
}
return;
}
const target = view === undefined ? this._previousView() : view;
// Following a `back_path` goes back too, so the view it goes to leaves
// the views before
if (
view !== undefined &&
this.isSameView(this._history[this._history.length - 1], view)
) {
this._history.pop();
}
if (!this.isSameView(target, this.view)) {
this._skipHistory = true;
this.view = target;
}
}
// The last view before that isn't the one shown, else the first view
private _previousView(): string | undefined {
while (this._history.length) {
const previous = this._history.pop();
if (!this.isSameView(previous, this.view)) {
return previous;
}
}
return undefined;
}
static styles = [
EmbedLovelaceElement.styles,
css`
/* Contained, so that the toolbar and background of hui-root, which are
fixed to the window in the app, are fixed to the element */
:host {
height: 600px;
contain: layout paint;
}
.theme,
.scroller {
height: 100%;
}
.scroller {
overflow: auto;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-dashboard": HaEmbedDashboard;
}
}
+321
View File
@@ -0,0 +1,321 @@
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 { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
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 type { DemoDevice } from "../stubs/device_registry";
import { toDeviceRegistryEntry } from "../stubs/device_registry";
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[];
devices?: DemoDevice[];
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 embed elements inside it.
* Elements 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`, `devices`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/**
* All areas. Without them, an area for each `area_id` of the entities and
* devices.
*/
@property({ attribute: false }) public areas?: DemoArea[];
/** All devices, which entities reference with their `device_id`. */
@property({ attribute: false }) public devices?: DemoDevice[];
/**
* 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)
);
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
Object.values(currentHass.devices)
);
// The registry of the entities, with the options each entity sets, such
// as timer presets. Other settings, such as calendar colors, are not set.
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,
options:
this.entities.find((item) => item.entity_id === entity.entity_id)
?.options ?? 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.devices = data.devices;
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") ||
changedProps.has("devices")
) {
const devices = this.devices ?? [];
const areas: DemoArea[] =
this.areas ??
[
...new Set(
[...this.entities, ...devices].flatMap((item) =>
item.area_id === undefined ? [] : [item.area_id]
)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = toAreaRegistryEntry(area);
});
const devicesById: Record<string, DeviceRegistryEntry> = {};
devices.forEach((device) => {
devicesById[device.id] = toDeviceRegistryEntry(device);
});
// Before the entities, so that their names can include the area and
// device
this.hass.updateHass({ areas: areasById, devices: devicesById });
// Entities that services changed keep their state when only the areas
// or devices 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 };
}
}
+95
View File
@@ -0,0 +1,95 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
import "../../../src/panels/lovelace/sections/hui-section";
import {
renderSectionContainer,
sectionContainerStyles,
} from "../../../src/panels/lovelace/sections/section-container";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a dashboard section, or a section strategy, with the backend of a
* provider, see `EmbedElement`. The section takes the width of the element.
*/
@customElement("ha-embed-section")
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
// A title becomes a heading card, as on a dashboard
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
// A section has cards, sections inside it, a strategy that makes them, a
// title, or the type of a custom section
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
return (
"cards" in data ||
"sections" in data ||
"strategy" in data ||
"title" in data ||
("type" in data && typeof data.type === "string")
);
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// The section that hid the element is gone, and the next one starts
// visible without telling it
if (changedProps.has("config") && !this.config) {
this.hidden = false;
}
}
// In the container that draws the background of the section, as in a
// sections view
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
const config = this._normalizedConfig(this.config);
return renderSectionContainer(
hass,
config,
html`<hui-section
.hass=${hass}
.config=${config}
@section-visibility-changed=${this._visibilityChanged}
></hui-section>`
);
}
private _visibilityChanged(
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
) {
// Sections inside the section fire the same event
if (ev.target !== ev.currentTarget) {
return;
}
this.hidden = !ev.detail.value;
}
static styles = [
embedElementStyles,
sectionContainerStyles,
css`
:host {
display: block;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-section": HaEmbedSection;
}
}
+134
View File
@@ -0,0 +1,134 @@
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import memoizeOne from "memoize-one";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import "../../../src/panels/lovelace/views/hui-view";
import "../../../src/panels/lovelace/views/hui-view-background";
import "../../../src/panels/lovelace/views/hui-view-container";
import type { HomeAssistant } from "../../../src/types";
import {
EmbedLovelaceElement,
findViewIndex,
isDashboardConfig,
} from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard view, a view strategy, or one view of a dashboard or
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
* `config` holds the view.
*/
@customElement("ha-embed-view")
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
/**
* A dashboard configuration or dashboard strategy, to show its `view`
* instead of `config`. Also read from the `dashboard` key of a script child,
* next to `view`.
*/
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
private _viewDashboard = memoizeOne(
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
);
protected get rawConfig() {
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
}
// A view strategy and a dashboard strategy both have a `strategy` key, so
// a dashboard comes in its own key, which no view has
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
return !("dashboard" in data);
}
protected setScriptData(data: ScriptObject) {
if (!("dashboard" in data)) {
super.setScriptData(data);
return;
}
const { dashboard } = data;
if (
typeof dashboard === "object" &&
dashboard !== null &&
isDashboardConfig(dashboard)
) {
this.dashboard = dashboard;
}
if (
"view" in data &&
(typeof data.view === "string" || typeof data.view === "number")
) {
this.view ??= String(data.view);
}
}
// Nothing for a dashboard without views, such as an empty strategy result.
// The view's theme and background, or the dashboard's background, as in
// hui-root. Each view gets its own hui-view, as there, so that a view
// strategy still generating for the view before can't replace the cards of
// this one.
protected renderContent(hass: HomeAssistant) {
const views = this._lovelace?.config.views;
if (!this._lovelace || !views?.length) {
return undefined;
}
const index = findViewIndex(views, this.view);
const viewConfig = views[index];
const background =
viewConfig.background || this._lovelace.config.background;
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
<hui-view-background .hass=${hass} .background=${background}>
</hui-view-background>
${keyed(
viewConfig,
html`<hui-view
.hass=${hass}
.lovelace=${this._lovelace}
.index=${index}
.narrow=${this._narrow.value ?? false}
></hui-view>`
)}
</hui-view-container>`;
}
// A fixed background stays inside the element. With a height set on the
// element, a panel view fills it, and other views keep their own height.
static styles = [
EmbedLovelaceElement.styles,
css`
:host {
contain: layout paint;
}
.theme,
hui-view-container,
hui-view {
display: block;
height: 100%;
}
/* The background sits below the view, above the element's own */
hui-view-container {
position: relative;
isolation: isolate;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-view": HaEmbedView;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css } from "lit";
import { property, state } from "lit/decorators";
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import {
checkStrategyShouldRegenerate,
generateLovelaceDashboardStrategy,
} from "../../../src/panels/lovelace/strategies/get-strategy";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
// The width below which the app is narrow, see home-assistant-main
const NARROW_WIDTH = 870;
/** The index of the view with this path or index, if there is one. */
const viewIndex = (
views: LovelaceViewRawConfig[],
view: string
): number | undefined => {
const byPath = views.findIndex((item) => item.path === view);
if (byPath !== -1) {
return byPath;
}
const index = Number(view);
return Number.isInteger(index) && index >= 0 && index < views.length
? index
: undefined;
};
/** Whether script data is a dashboard: its views, or a strategy for them. */
export const isDashboardConfig = (
data: ScriptObject
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
/** The index of the view with this path or index, else the first view. */
export const findViewIndex = (
views: LovelaceViewRawConfig[],
view?: string
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
/**
* The base of the embed elements that render a dashboard, or one of its
* views. It generates dashboard strategies, again when the registries they
* depend on change, and holds a dashboard that can't be edited or saved.
*/
export abstract class EmbedLovelaceElement<
Config extends ScriptObject,
> extends EmbedElement<Config> {
/** The path or index of the view to show. Without it, the first view. */
@property() public view?: string;
@state() protected _lovelace?: Lovelace;
// Narrow like the app at the same width, measured on the element because
// it rarely takes the width of the page
protected _narrow = new ResizeController(this, {
callback: (entries) =>
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
});
// The configuration of the dashboard in `_lovelace`, or being generated
private _rawConfig?: LovelaceRawConfig;
// Counts the generations started, so that only the latest one is shown.
// Generating again for changed registries keeps the same configuration.
private _generation = 0;
// The backend at the last update, to tell what a strategy depends on changed
private _previousHass?: HomeAssistant;
/** The dashboard to render. The same object until it changes. */
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
/**
* The view of the dashboard a path goes to, relative like `batteries` or
* with the dashboard like `/maintenance/batteries`, if it has that view.
*/
protected viewOfPath(path: string | undefined): string | undefined {
const views = this._lovelace?.config.views;
const view = path
? decodeURI(new URL(path, "http://embed/").pathname)
.split("/")
.filter(Boolean)
.pop()
: undefined;
return views && view !== undefined && viewIndex(views, view) !== undefined
? view
: undefined;
}
/** Whether two paths or indexes go to the same view of the dashboard. */
protected isSameView(a?: string, b?: string): boolean {
const views = this._lovelace?.config.views;
return views
? findViewIndex(views, a) === findViewIndex(views, b)
: a === b;
}
// A path to one of the dashboard's views shows that view. Other paths leave
// the dashboard where it is.
protected navigateTo(path: string) {
const view = this.viewOfPath(path);
if (view !== undefined) {
this.view = view;
}
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
const raw = this.rawConfig;
const oldHass = this._previousHass;
this._previousHass = this._hass;
// Without a configuration, nothing is shown, and a generation still
// running is dropped
if (!raw) {
if (this._rawConfig) {
this._rawConfig = undefined;
this._lovelace = undefined;
this._generation++;
}
return;
}
if (!this._hass) {
return;
}
// A strategy is only asked whether to generate again when the backend
// changed, as in ha-panel-lovelace, not when the generated dashboard
// arrives
if (
raw !== this._rawConfig ||
(oldHass &&
oldHass !== this._hass &&
isStrategyDashboard(raw) &&
this._hass.config.state === "RUNNING" &&
checkStrategyShouldRegenerate(
"dashboard",
raw.strategy,
oldHass,
this._hass
))
) {
this._rawConfig = raw;
this._generate(raw, this._hass, ++this._generation);
}
}
private async _generate(
raw: LovelaceRawConfig,
hass: HomeAssistant,
generation: number
) {
const generated = isStrategyDashboard(raw)
? await generateLovelaceDashboardStrategy(raw, hass)
: raw;
const config = normalizeLovelaceConfig(generated);
// A newer generation has started. A generated dashboard has its views,
// rather than a strategy.
if (generation !== this._generation || isStrategyDashboard(config)) {
return;
}
this._lovelace = {
config,
rawConfig: raw,
editMode: false,
urlPath: null,
mode: "generated",
locale: hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
embedded: true,
};
}
// The background of a dashboard, and no insets for a notch or home bar
static styles: CSSResultGroup = [
embedElementStyles,
css`
:host {
display: block;
--app-safe-area-inset-top: 0px;
--app-safe-area-inset-bottom: 0px;
--app-safe-area-inset-left: 0px;
--app-safe-area-inset-right: 0px;
}
.theme {
position: relative;
background: var(--primary-background-color);
}
`,
];
}
+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 {};
+31
View File
@@ -0,0 +1,31 @@
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!);
};
/** The data of a script child that holds an object, such as a config. */
export type ScriptObject = object;
/** The object in a script child, see `readScriptData`, if it holds one. */
export const readScriptObject = (
element: HTMLElement
): ScriptObject | undefined => {
const data = readScriptData(element);
return typeof data === "object" && data !== null && !Array.isArray(data)
? data
: undefined;
};
+95
View File
@@ -0,0 +1,95 @@
import { css, 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("")}}`
);
/**
* The styles of every embed element: the theme, and the page styles of the
* app, for the `.theme` wrapper of `EmbedElement`.
*/
export const embedElementStyles = [
embedThemeStyles,
css`
:host([hidden]) {
display: none;
}
/* The text styles the app sets on html and body, so that the content 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 content */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
+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);
File diff suppressed because it is too large. Load diff
+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")
);
});
};
+54
View File
@@ -1,6 +1,60 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/** The fields of a device that the demo devices leave at their default. */
export const baseDevice: Omit<
DeviceRegistryEntry,
| "id"
| "name"
| "manufacturer"
| "model"
| "config_entries"
| "primary_config_entry"
| "entry_type"
> = {
config_entries_subentries: {},
connections: [],
identifiers: [],
model_id: null,
labels: [],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
export interface DemoDevice {
id: string;
name: string;
manufacturer?: string;
model?: string;
area_id?: string;
via_device_id?: string;
entry_type?: "service";
}
export const toDeviceRegistryEntry = (
device: DemoDevice
): DeviceRegistryEntry => ({
...baseDevice,
id: device.id,
name: device.name,
manufacturer: device.manufacturer ?? null,
model: device.model ?? null,
area_id: device.area_id ?? null,
via_device_id: device.via_device_id ?? null,
entry_type: device.entry_type ?? null,
config_entries: [],
primary_config_entry: null,
});
export const mockDeviceRegistry = (
hass: MockHomeAssistant,
data: DeviceRegistryEntry[] = []
+1 -19
View File
@@ -1,24 +1,6 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
const baseDevice = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
import { baseDevice } from "./device_registry";
export const demoDevices: DeviceRegistryEntry[] = [
{
+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);
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20261028.0"
version = "20261028.5"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -3,6 +3,12 @@ import { ref } from "lit/directives/ref";
const MAX_ROWS = 2;
// A narrow list, as on a phone or a half width card, fits about one item per
// row, so two rows hide most of the legend. 450px is about the widest list a
// phone viewport gives.
const NARROW_MAX_ROWS = 3;
const NARROW_WIDTH = 450;
// How many items fit in maxRows of a wrapping row, leaving room for the
// "more" item after them when not all of them fit.
export const fitInRows = (
@@ -34,9 +40,10 @@ export const fitInRows = (
return widths.length;
};
// Fits the items of a wrapping legend list into two rows. The list keeps its
// "more" item rendered with the legend-more class, and items past the fit
// stay rendered out of the flow, so both can always be measured.
// Fits the items of a wrapping legend list into two rows, three when it is
// narrow. The list keeps its "more" item rendered with the legend-more class,
// and items past the fit stay rendered out of the flow, so both can always be
// measured.
export class LegendFitController {
// Items to show while collapsed, undefined until the list is measured
public fit?: number;
@@ -118,7 +125,7 @@ export class LegendFitController {
more?.getBoundingClientRect().width ?? 0,
rowWidth,
parseFloat(getComputedStyle(list).columnGap) || 0,
MAX_ROWS
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
);
if (fit !== this.fit) {
this.fit = fit;
@@ -144,7 +144,8 @@ export class MediaProgressController implements ReactiveController {
return;
}
slider.valueFormatter = formatMediaTime;
if (slider.matches(":state(dragging)")) {
// matches(":state(dragging)") throws before Chromium 125.
if (slider.customStates.has("dragging")) {
return;
}
slider.value = this.progress ?? 0;
+1 -1
View File
@@ -1,5 +1,5 @@
// The attributes that hold the address of a link or an image
const URL_ATTRIBUTES = [
export const URL_ATTRIBUTES = [
["a", "href"],
["img", "src"],
["source", "src"],
+15 -2
View File
@@ -1,5 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeCssColor } from "../color/compute-color";
import { computeDomain } from "./compute_domain";
import { stateColorCss } from "./state_color";
export const ALERT_DOMAINS = [
"alarm_control_panel",
@@ -8,8 +10,6 @@ export const ALERT_DOMAINS = [
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
@@ -25,3 +25,16 @@ export const computeAlertActiveStates = (entityId: string): string[] => {
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
export const computeAlertColor = (
stateObj: HassEntity,
color: string
): string | undefined => {
if (color === "none") {
return undefined;
}
if (color === "state") {
return stateColorCss(stateObj);
}
return computeCssColor(color);
};
@@ -6,6 +6,7 @@ import {
} from "../../data/entity/entity_attributes";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { FrontendLocaleData } from "../../data/translation";
import { TimeZone } from "../../data/translation";
import type { WeatherEntity } from "../../data/weather";
import { getWeatherUnit } from "../../data/weather";
import type { HomeAssistant, ValuePart } from "../../types";
@@ -115,6 +116,24 @@ export const computeAttributeValueToParts = (
}
}
// Date only, which is timezone agnostic, so parse and format it as
// local time to keep the same calendar day
if (isDate(attributeValue)) {
const date = new Date(`${attributeValue}T00:00`);
if (checkValidDate(date)) {
return [
{
type: "value",
value: formatDate(
date,
{ ...locale, time_zone: TimeZone.local },
config
),
},
];
}
}
// Value was not a timestamp, so only do date formatting
const date = new Date(attributeValue);
if (checkValidDate(date)) {
+10 -5
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
@@ -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 (
+1 -1
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;
@@ -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 };
@@ -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;
+15 -6
View File
@@ -89,6 +89,8 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
export type ZoomSliderMode = "auto" | "always" | "never";
const DEFERRED_PROPS = [
"options",
"data",
@@ -163,9 +165,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "small-controls", type: Boolean })
public smallControls?: boolean;
// For charts zoomed from outside, like the synced History charts
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
// "auto" shows the zoom slider once zoomed. "never" hides every zoom
// control, for charts zoomed from outside like the synced History charts.
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
// For charts sized to their content, like timelines, which grow to fit the
// zoom slider instead of giving up plot height
@@ -617,7 +620,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
>
${
(this._isZoomed || this._isGraphRoamed) &&
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
!showZoomSlider
? html`<ha-icon-button
class="zoom-reset"
@@ -1064,9 +1067,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _showZoomSlider() {
const [start, end] = this._zoomRange;
return (
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
this._hasZoomableXAxis() &&
(this._zoomSliderHeld || start !== 0 || end !== 100)
(this.zoomSlider === "always" ||
this._zoomSliderHeld ||
start !== 0 ||
end !== 100)
);
}
@@ -2306,6 +2312,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
--ha-assist-chip-trailing-space: var(--ha-space-2);
--ha-assist-chip-icon-label-space: var(--ha-space-1);
}
.zoom-reset {
margin-inline-end: var(--ha-space-3);
}
`;
}
+1 -1
View File
@@ -648,7 +648,7 @@ export class HaNetworkGraph extends LitElement {
ha-icon-button,
::slotted(ha-icon-button) {
margin-right: 12px;
margin-inline-end: var(--ha-space-3);
}
`;
}
@@ -24,6 +24,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import {
@@ -105,8 +106,8 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: LineSeriesOption[] = [];
@@ -159,7 +160,7 @@ export class StateHistoryChartLine extends LitElement {
@dataset-unhidden=${this._datasetUnhidden}
@chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
.clickLabelForMoreInfo=${this.clickForMoreInfo}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
@@ -22,6 +22,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -68,8 +69,8 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public chartIndex?;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: CustomSeriesOption[] = [];
@@ -105,7 +106,7 @@ export class StateHistoryChartTimeline extends LitElement {
grow-for-zoom-slider
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
></ha-chart-base>
`;
}
+2 -2
View File
@@ -218,7 +218,7 @@ export class StateHistoryCharts extends LitElement {
@chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-line>
</div> `;
}
@@ -237,7 +237,7 @@ export class StateHistoryCharts extends LitElement {
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
@chart-zoom-with-index=${this._handleTimelineSync}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-timeline>
</div> `;
};
+1
View File
@@ -352,6 +352,7 @@ export class HaChipBase extends HaButton {
}
.container {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
max-width: 100%;
+24 -21
View File
@@ -29,9 +29,9 @@ import type {
} from "../../common/translations/localize";
import { formattersContext, registriesContext } from "../../data/context";
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-button";
import "../ha-button-toggle-group";
import "../ha-combo-box-item";
import "../ha-generic-picker";
@@ -121,6 +121,8 @@ export class HaEntityNamePicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-button.add") private _addButton?: HTMLElement;
private _editIndex?: number;
connectedCallback(): void {
@@ -159,7 +161,7 @@ export class HaEntityNamePicker extends LitElement {
return html`
<div class="container">
<div class="header">
${this.label ? html`<label>${this.label}</label>` : nothing}
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
${
this.allowCustom
? html`
@@ -264,7 +266,9 @@ export class HaEntityNamePicker extends LitElement {
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
<ha-chip-set
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
this._items,
(item) => item,
@@ -290,22 +294,16 @@ export class HaEntityNamePicker extends LitElement {
`;
}
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`
}
<ha-button
class="add"
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._addItem}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this._localize("ui.common.add")}
</ha-button>
</ha-chip-set>
</ha-sortable>
${this._renderRestoreButton()}
@@ -373,6 +371,10 @@ export class HaEntityNamePicker extends LitElement {
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
if (!value.length) {
await this.updateComplete;
this._addButton?.focus();
}
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
@@ -629,7 +631,7 @@ export class HaEntityNamePicker extends LitElement {
label {
display: block;
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
}
.content {
@@ -722,7 +724,8 @@ export class HaEntityNamePicker extends LitElement {
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
padding: 0;
margin: var(--ha-space-1) 0 0;
}
`;
}
+4
View File
@@ -58,6 +58,10 @@ export class HaAnsiToHtml extends LitElement {
// The hidden lines each expand row shows when clicked.
private _expandGaps = new WeakMap<HTMLElement, HTMLDivElement[]>();
public get hasLines(): boolean {
return !!this._pre?.childElementCount;
}
protected render(): TemplateResult {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
+9 -3
View File
@@ -13,6 +13,7 @@ import {
import {
AREA_CONTROLS_BUTTONS,
getAreaControlEntities,
getAreaEntityIds,
type AreaControlDomain,
} from "../data/area/area_controls";
import {
@@ -148,15 +149,20 @@ export class HaAreaControlsPicker extends LitElement {
currentValue === id ||
(excludeValues !== undefined && excludeValues.includes(id));
const areaEntities = getAreaEntityIds(
registries.entities,
registries.devices,
areaId,
excludeEntities
);
const controlEntities = getAreaControlEntities(
areaEntities,
states,
registries.entities,
registries.devices,
registries.areas,
registries.floors,
AREA_CONTROL_DOMAINS,
areaId,
excludeEntities
AREA_CONTROL_DOMAINS
);
const items: (AreaControlPickerItem | string)[] = [];
@@ -245,15 +245,15 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const newOrder: string[] = [];
for (const floorId of floorIds) {
const isOnFloor = (areaId: string) =>
(this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR) === floorId;
if ((currentFloorId ?? UNASSIGNED_FLOOR) === floorId) {
newHidden.push(...(value.hidden ?? []));
// Reordering returns the hidden areas of every floor, not just this one
newHidden.push(...(value.hidden ?? []).filter(isOnFloor));
newOrder.push(...(value.order ?? []));
continue;
}
const hidden = oldHidden.filter((areaId) => {
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
const hidden = oldHidden.filter(isOnFloor);
if (hidden?.length) {
newHidden.push(...hidden);
}
+65 -33
View File
@@ -1,7 +1,9 @@
import type { ContextType } from "@lit/context";
import { mdiPlus } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -26,6 +28,10 @@ import "./chips/ha-input-chip";
import "./ha-combo-box-item";
import "./ha-domain-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-button";
import "./ha-svg-icon";
import "./ha-input-helper-text";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
@@ -82,6 +88,8 @@ export class HaEntityTypePicker extends LitElement {
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _titlesLoaded = false;
protected willUpdate() {
@@ -237,40 +245,13 @@ export class HaEntityTypePicker extends LitElement {
const localize = this._i18n?.localize;
const value = this.value ?? [];
const chipLabels = localize ? this._chipLabels(localize, value) : undefined;
const addLabel = localize?.("ui.common.add");
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${
value.length && chipLabels
? html`
<ha-chip-set>
${repeat(
value,
(key) => key,
(key) => {
const label = chipLabels.get(key)!;
return html`
<ha-input-chip
data-key=${key}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${this._renderIcon(key, "icon")} ${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
<ha-generic-picker
.helper=${this.helper}
.label=${this.label}
.disabled=${this.disabled}
.value=${""}
.addButtonLabel=${localize?.("ui.components.entity-type-picker.add")}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.searchFn=${this._searchFn}
@@ -278,10 +259,53 @@ export class HaEntityTypePicker extends LitElement {
.emptyLabel=${localize?.("ui.components.entity-type-picker.no_types")}
no-sort
@value-changed=${this._itemAdded}
></ha-generic-picker>
>
<ha-chip-set
slot="field"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
value,
(key) => key,
(key) => {
const label = chipLabels?.get(key) ?? key;
return html`
<ha-input-chip
data-key=${key}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${this._renderIcon(key, "icon")} ${label}
</ha-input-chip>
`;
}
)}
<ha-button
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${addLabel}
</ha-button>
</ha-chip-set>
</ha-generic-picker>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _itemAdded(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const key = ev.detail.value;
@@ -320,7 +344,15 @@ export class HaEntityTypePicker extends LitElement {
width: 100%;
}
ha-chip-set {
padding: var(--ha-space-2) 0;
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
ha-input-helper-text {
padding: 0;
margin-top: var(--ha-space-1);
}
`;
}
+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),
}
);
+10
View File
@@ -376,6 +376,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
virtual-focus
controlled
tabindex="0"
@mousedown=${this._preventBlur}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
>
@@ -406,6 +407,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.value
)}
.pinIndex=${this.value ? this._getInitialSelectedIndex() : undefined}
@mousedown=${this._preventBlur}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@ha-list-visibility-changed=${this._visibilityChanged}
@@ -430,6 +432,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>`
)}
@@ -684,6 +692,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._resetListScroll();
};
// Keep focus in the search field, so a focus change doesn't scroll the
// list to the active row and make the click miss.
private _preventBlur(ev: Event) {
ev.preventDefault();
}
@@ -1,26 +1,28 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { ensureArray } from "../../common/array/ensure-array";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { internationalizationContext } from "../../data/context";
import type { SelectOption, SelectSelector } from "../../data/selector";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistantInternationalization } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-checkbox";
import "../ha-dropdown-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-button";
import "../ha-input-helper-text";
import "../ha-select";
import "../ha-select-box";
import "../ha-sortable";
import "../ha-svg-icon";
import "../radio/ha-radio-group";
import "../radio/ha-radio-option";
@@ -28,10 +30,7 @@ import "../radio/ha-radio-option";
export class HaSelectSelector extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
private _i18n!: HomeAssistantInternationalization;
@property({ attribute: false }) public selector!: SelectSelector;
@@ -48,6 +47,8 @@ export class HaSelectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
@@ -83,7 +84,11 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.sort) {
options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
caseInsensitiveStringCompare(
a.label,
b.label,
this._i18n.locale.language
)
);
}
@@ -158,67 +163,80 @@ export class HaSelectSelector extends LitElement {
const value =
!this.value || this.value === "" ? [] : ensureArray(this.value);
const addLabel = this._i18n.localize("ui.common.add");
return html`
${
value?.length
? html`
<ha-sortable
no-style
.disabled=${!this.selector.select.reorder}
@item-moved=${this._itemMoved}
handle-selector="button.primary.action"
>
<ha-chip-set>
${repeat(
value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)
?.label || item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
this.selector.select?.reorder
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing
}
${
options.find((option) => option.value === item)
?.label || item
}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
</ha-sortable>
`
this.label
? html`<span id="label" class="label chips-label"
>${this.label}</span
>`
: nothing
}
<ha-generic-picker
no-sort
.helper=${this.helper}
.label=${this.label}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.addButtonLabel=${this.label}
.getItems=${this._getItems(options, value, true)}
.allowCustomValue=${this.selector.select.custom_value ?? false}
@value-changed=${this._comboBoxValueChanged}
></ha-generic-picker>
>
<ha-sortable
slot="field"
no-style
.disabled=${!this.selector.select.reorder}
@item-moved=${this._itemMoved}
draggable-selector="ha-input-chip"
handle-selector="button.primary.action"
>
<ha-chip-set
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)?.label ||
item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
this.selector.select?.reorder
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing
}
${label}
</ha-input-chip>
`;
}
)}
<ha-button
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${addLabel}
</ha-button>
</ha-chip-set>
</ha-sortable>
</ha-generic-picker>
${this._renderHelper()}
`;
}
@@ -263,6 +281,11 @@ export class HaSelectSelector extends LitElement {
: "";
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _getOptions = memoizeOne(
(selector: SelectSelector) =>
selector.select?.options?.map((option) =>
@@ -403,19 +426,32 @@ export class HaSelectSelector extends LitElement {
ha-dropdown-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
ha-chip-set {
padding: 8px 0;
}
.label {
display: block;
margin: 0 0 8px;
}
.chips-label {
font-weight: var(--ha-font-weight-medium);
}
ha-sortable ha-chip-set {
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
ha-select-box + ha-input-helper-text {
margin-top: 4px;
}
ha-generic-picker + ha-input-helper-text {
padding: 0;
margin-top: 4px;
}
.checkbox-list ha-input-helper-text {
padding: 0;
margin-top: 4px;
@@ -98,8 +98,9 @@ export class HaUnitOfMeasurementSelector extends LitElement {
return;
}
if (this.required && units.length === 1 && units[0] !== null) {
// Preselect the only allowed unit
if (units.length === 1 && units[0] !== null) {
// Preselect the only allowed unit; "no unit" is not allowed either, so
// this also applies to an optional field
fireEvent(this, "value-changed", { value: units[0] });
}
}
+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,
@@ -0,0 +1,77 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { css, html, LitElement, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import "../animation/ha-fade-in";
import "./ha-skeleton-text";
// Varied widths so the placeholder reads as log lines rather than a block
const LINE_WIDTHS = [72, 58, 86, 64, 44, 78, 52, 90, 60, 70];
const LINE_HEIGHT_EM = 1.5;
/**
* Placeholder for log lines while a log loads. It covers its nearest
* positioned ancestor and shows as many whole lines as fit, so it never
* scrolls. Like the data table's loading rows, the label is given to assistive
* technology rather than shown.
*
* @cssprop --ha-skeleton-log-padding - The space around the lines. Defaults to `var(--ha-space-2)`.
*/
@customElement("ha-skeleton-log")
export class HaSkeletonLog extends LitElement {
@property() public label?: string;
private _lineCount = new ResizeController(this, {
callback: (entries) => {
const height = entries[0]?.contentRect.height ?? 0;
const lineHeight =
parseFloat(getComputedStyle(this).fontSize) * LINE_HEIGHT_EM;
return lineHeight ? Math.floor(height / lineHeight) : 0;
},
});
protected render() {
return html`
<ha-fade-in .duration=${300} easing="ease-in">
<div role="progressbar" aria-label=${ifDefined(this.label)}>
${Array.from(
{ length: this._lineCount.value ?? 0 },
(_, i) =>
html`<div class="line">
<ha-skeleton-text
style=${styleMap({
"--ha-skeleton-text-width": `${LINE_WIDTHS[i % LINE_WIDTHS.length]}%`,
})}
></ha-skeleton-text>
</div>`
)}
</div>
</ha-fade-in>
`;
}
static styles = css`
:host {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: var(--ha-skeleton-log-padding, var(--ha-space-2));
overflow: hidden;
}
.line {
display: flex;
align-items: center;
height: ${unsafeCSS(LINE_HEIGHT_EM)}em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton-log": HaSkeletonLog;
}
}
@@ -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>`;
+16 -7
View File
@@ -1,3 +1,4 @@
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
import { generateEntityFilter } from "../../common/entity/entity_filter";
import type { HomeAssistant } from "../../types";
@@ -61,15 +62,26 @@ export const AREA_CONTROLS_BUTTONS: Record<
"cover-window": coverButton("window"),
};
export const getAreaEntityIds = (
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areaId: string,
excludeEntities?: string[]
): string[] =>
Object.keys(entities).filter(
(entityId) =>
getEntityAreaId(entityId, entities, devices) === areaId &&
!excludeEntities?.includes(entityId)
);
export const getAreaControlEntities = (
areaEntities: string[],
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
controls: readonly AreaControlDomain[],
areaId: string,
excludeEntities: string[] | undefined
controls: readonly AreaControlDomain[]
): Record<AreaControlDomain, string[]> =>
controls.reduce(
(acc, control) => {
@@ -81,15 +93,12 @@ export const getAreaControlEntities = (
areas,
floors,
{
area: areaId,
entity_category: "none",
...controlButton.filter,
}
);
acc[control] = Object.keys(entities).filter(
(entityId) => filter(entityId) && !excludeEntities?.includes(entityId)
);
acc[control] = areaEntities.filter(filter);
return acc;
},
{} as Record<AreaControlDomain, string[]>
+2 -1
View File
@@ -19,7 +19,8 @@ export interface ESPHomeZWaveProxyCapabilities {
export interface ESPHomeSerialProxy {
name: string;
port_type: ESPHomeSerialPortType | null;
url: string;
// `null` when no device is present (e.g. unpopulated USB port)
url: string | null;
}
export interface ESPHomeDeviceCapabilities {
+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,
+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) : [],
@@ -837,6 +837,9 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
return [
haStyleDialog,
css`
ha-dialog {
--ha-dialog-header-title-height: auto;
}
.dialog-title {
overflow: hidden;
text-overflow: ellipsis;
@@ -690,7 +690,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
* is always in the scan, so a missing or absent port means it is offline.
* Without the usb integration nothing is scanned, so a missing port says nothing.
*/
private _serialPortUsage(url: string) {
private _serialPortUsage(url: string | null) {
if (!this._serialPorts) {
return undefined;
}
@@ -43,6 +43,7 @@ export class MoreInfoLogbook extends LitElement {
.time=${this._time}
.entityIds=${this._entityIdAsList(this.entityId)}
name-detail="none"
max-entries="100"
narrow
no-icon
graph-color
@@ -375,21 +375,16 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
margin-top: 24px;
}
.rows {
gap: var(--ha-space-4);
display: flex;
flex-direction: column;
}
.row {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--ha-space-4) var(--ha-space-1);
align-items: center;
}
.row > *:first-child {
flex: 1;
margin-right: 4px;
.row {
display: contents;
}
.row ha-button {
width: 82px;
.row > :only-child {
grid-column: 1 / -1;
}
ha-select {
+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);
};
+6
View File
@@ -2,6 +2,7 @@ import type {
HassEntity,
HassEntityAttributeBase,
} from "home-assistant-js-websocket";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
/** Base attributes type that all entities share */
export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
@@ -17,6 +18,11 @@ export type EntityInput = Pick<
device_id?: string;
/** Integration that provides the entity, defaults to "demo" */
platform?: string;
/**
* Options of the entity in the mocked entity registry, such as timer
* presets or favorite positions
*/
options?: EntityRegistryEntry["options"];
/** When the state last changed, a random recent time by default */
last_changed?: string;
};
+65 -14
View File
@@ -5,6 +5,7 @@ import {
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeEntityNameDisplayWithoutContext } from "../common/entity/compute_entity_name_display";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
@@ -46,6 +47,7 @@ import { demoPanels } from "./demo_panels";
import { demoServices } from "./demo_services";
import { ENTITY_COMPONENT_ICONS } from "./entity_component_icons";
import { getEntity } from "./entities/registry";
import type { MockBaseEntity } from "./entities/base-entity";
import type { EntityInput } from "./entities/types";
const ensureArray = <T>(val: T | T[]): T[] =>
@@ -75,6 +77,8 @@ export interface MockHomeAssistant extends HomeAssistant {
updateHass(obj: Partial<MockHomeAssistant>);
updateStates(newStates: HassEntities);
addEntities(entities: EntityInput | EntityInput[], replace?: boolean);
// Recomputes the format functions, such as after the areas change.
updateFormatFunctions(): Promise<void>;
updateTranslations(fragment: null | string, language?: string);
addTranslations(translations: Record<string, string>, language?: string);
mockWS<T extends (...args) => any = any>(
@@ -297,34 +301,77 @@ export const provideHass = (
});
}
// Like Core, an entity of a device has only its own name, which follows the
// name of the device in its friendly name. An entity named like its device
// has no name of its own.
const registryName = (
ent: MockBaseEntity
): Pick<EntityRegistryDisplayEntry, "name" | "has_entity_name"> => {
const friendlyName: string | undefined =
ent.attributes.friendly_name || undefined;
const device = ent.deviceId ? hass().devices[ent.deviceId] : undefined;
const deviceName = device ? computeDeviceName(device) : undefined;
if (
!deviceName ||
!friendlyName ||
(friendlyName !== deviceName &&
!friendlyName.startsWith(`${deviceName} `))
) {
return { name: friendlyName };
}
return {
name: friendlyName.slice(deviceName.length).trim() || undefined,
has_entity_name: true,
};
};
function addEntities(
newEntities: EntityInput | EntityInput[],
replace = false
) {
if (replace) {
// Drop the old mock entities too, so that services cannot bring them back.
for (const entityId of Object.keys(entities)) {
delete entities[entityId];
}
}
const states = {};
// A new object, so that registry consumers see the change
const registry: HomeAssistant["entities"] = replace
? {}
: { ...hass().entities };
ensureArray(newEntities).forEach((input) => {
const ent = getEntity(input);
ent.hass = hass();
entities[ent.entityId] = ent;
states[ent.entityId] = ent.toState();
hass().entities[ent.entityId] = {
const name = registryName(ent);
registry[ent.entityId] = {
entity_id: ent.entityId,
name: ent.attributes.friendly_name || undefined,
...name,
icon: undefined,
platform: ent.platform ?? "demo",
labels: [],
area_id: ent.areaId,
device_id: ent.deviceId,
// Only follow the device when its name was split off, so a friendly
// name that doesn't start with it is shown as it is
next_name_part: ent.areaId
? "area"
: name.has_entity_name
? "device"
: undefined,
} satisfies EntityRegistryDisplayEntry;
});
if (replace) {
hass().updateHass({
states,
});
} else {
updateStates(states);
}
hass().updateHass({
entities: registry,
states: replace ? states : { ...hass().states, ...states },
});
updateFormatFunctions();
}
@@ -347,15 +394,18 @@ export const provideHass = (
const localLanguage = getLocalLanguage();
const noop = () => undefined;
const auth = {
data: {
hassUrl: location.origin,
},
} as any;
const hassObj: MockHomeAssistant = {
// Home Assistant properties
auth: {
data: {
hassUrl: location.origin,
},
} as any,
auth,
connection: {
// The map reads the URL of Home Assistant from here
options: { auth },
addEventListener: noop,
removeEventListener: noop,
sendMessage: (msg) => {
@@ -540,6 +590,7 @@ export const provideHass = (
return hass().localize;
},
addEntities,
updateFormatFunctions,
mockWS(type, callback) {
wsCommands[type] = callback;
},
@@ -1064,6 +1064,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
align-items: center;
color: var(--secondary-text-color);
}
.search-toolbar ha-input-search {
min-width: 0;
}
.filters {
--mdc-text-field-fill-color: var(--input-fill-color);
--mdc-text-field-idle-line-color: var(--input-idle-line-color);
+7 -1
View File
@@ -180,6 +180,13 @@ export class HomeAssistantMain extends LitElement {
color: var(--primary-text-color);
/* remove the grey tap highlights in iOS on the fullscreen touch targets */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
/* Keep the negative z-index panel backgrounds (the resolver's ::before
and the Lovelace view background) in this host's stacking context.
Isolating the conditionally rendered partial-panel-resolver instead
can intermittently leave the Android WebView (Chromium 109-124)
accessibility tree with an empty panel subtree while the page paints
normally, which breaks UI automation such as the companion app E2E. */
isolation: isolate;
--ha-sidebar-width: calc(56px + var(--safe-area-inset-left, 0px));
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
--safe-area-content-inset-left: 0px;
@@ -201,7 +208,6 @@ export class HomeAssistantMain extends LitElement {
partial-panel-resolver {
display: block;
height: 100%;
isolation: isolate;
}
partial-panel-resolver::before {
content: "";
+3 -2
View File
@@ -197,9 +197,10 @@ class OnboardingCoreConfig extends LitElement {
longitude: this._location[1],
elevation: Number(this._elevation),
unit_system:
this._unitSystem || ["US", "MM", "LR"].includes(this._country)
this._unitSystem ||
(["US", "MM", "LR"].includes(this._country)
? "us_customary"
: "metric",
: "metric"),
time_zone: this._timeZone,
currency: this._currency || countryCurrency[this._country] || "EUR",
country: this._country,
@@ -942,6 +942,7 @@ class HaConfigAreaPage extends LitElement {
:host {
display: block;
height: 100%;
}
.container {
display: flex;
@@ -20,6 +20,7 @@ import {
mdiStopCircleOutline,
mdiSwapHorizontal,
} from "@mdi/js";
import { provide } from "@lit/context";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
@@ -29,6 +30,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../../../../common/decorators/consume";
import "../trigger/ha-automation-trigger-references";
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
@@ -165,6 +167,11 @@ const getWaitTemplateCondition = (template: unknown): Condition | undefined =>
@customElement("ha-automation-action-row")
export default class HaAutomationActionRow extends LitElement {
// Conditions don't expand the groups their targets name; other actions do.
@provide({ context: targetExpandGroupContext })
@state()
protected _targetExpandGroup = true;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public action!: Action;
@@ -257,6 +264,7 @@ export default class HaAutomationActionRow extends LitElement {
return;
}
const type = getAutomationActionType(this.action);
this._targetExpandGroup = type !== "condition";
if (this._warnings && this._yamlMode && type) {
this._warnings = getUiSupportWarnings(
this.hass.localize,
@@ -37,6 +37,7 @@ import {
isDynamic,
} from "../../../data/automation";
import { getConditionDomain } from "../../../data/condition";
import type { ConditionDescriptions } from "../../../data/condition";
import {
apiContext,
conditionDescriptionsContext,
@@ -56,6 +57,7 @@ import {
getTriggerIcons,
} from "../../../data/icons";
import { domainToName } from "../../../data/integration";
import type { DomainManifestLookup } from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
import {
TARGET_SEPARATOR,
@@ -63,6 +65,7 @@ import {
type SingleHassServiceTarget,
} from "../../../data/target";
import { getTriggerDomain } from "../../../data/trigger";
import type { TriggerDescriptions } from "../../../data/trigger";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { haStyleScrollbar } from "../../../resources/styles";
@@ -154,33 +157,29 @@ class DialogAddAutomationElement extends DialogMixin<AddAutomationElementDialogP
@consume({ context: statesContext, subscribe: true })
@transform<HomeAssistant["states"], Set<string>>({
transformer: function (this: DialogAddAutomationElement, states) {
const domains = new Set(Object.keys(states).map(computeDomain));
const entityIds = new Set(Object.keys(states));
// keep the previous set, so a state change doesn't rerender the dialog
return deepEqual(domains, this._usedDomains)
? this._usedDomains
: domains;
return deepEqual(entityIds, this._entityIds)
? this._entityIds
: entityIds;
},
})
private _usedDomains?: Set<string>;
private _entityIds?: Set<string>;
@state()
@consume({ context: statesContext, subscribe: true })
@transform<HomeAssistant["states"], SystemDomains | undefined>({
transformer: function (this: DialogAddAutomationElement, states) {
const systemDomains = this._calculateSystemDomains(states);
// keep the previous result, so a state change doesn't rerender the dialog
return deepEqual(systemDomains, this._systemDomains)
? this._systemDomains
: systemDomains;
},
watch: [
"_manifests",
"_triggerDescriptions",
"_conditionDescriptions",
"_registries",
],
})
private _systemDomains?: SystemDomains;
private get _usedDomains(): Set<string> | undefined {
return this._computeUsedDomains(this._entityIds);
}
private get _systemDomains(): SystemDomains | undefined {
return this._calculateSystemDomains(
this.params?.type,
this._triggerDescriptions,
this._conditionDescriptions,
this._manifests,
this._registries,
this._entityIds
);
}
@state()
@consume({ context: servicesContext, subscribe: true })
@@ -884,32 +883,43 @@ class DialogAddAutomationElement extends DialogMixin<AddAutomationElementDialogP
})
);
private _calculateSystemDomains(
states: HomeAssistant["states"]
): SystemDomains | undefined {
const type = this.params?.type;
if (!type || type === "action" || !this._manifests || !this._registries) {
return undefined;
private _computeUsedDomains = memoizeOne(
(entityIds: Set<string> | undefined) =>
entityIds && new Set(Array.from(entityIds, computeDomain))
);
private _calculateSystemDomains = memoizeOne(
(
type: AddAutomationElementDialogParams["type"] | undefined,
triggerDescriptions: TriggerDescriptions,
conditionDescriptions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
registries: ContextType<typeof registriesContext> | undefined,
_entityIds: Set<string> | undefined
): SystemDomains | undefined => {
if (!type || type === "action" || !manifests || !registries) {
return undefined;
}
const { entities, devices } = registries;
return type === "trigger"
? calculateActiveSystemDomains(
triggerDescriptions,
manifests,
getTriggerDomain,
this._states,
entities,
devices
)
: calculateActiveSystemDomains(
conditionDescriptions,
manifests,
getConditionDomain,
this._states,
entities,
devices
);
}
const { entities, devices } = this._registries;
return type === "trigger"
? calculateActiveSystemDomains(
this._triggerDescriptions,
this._manifests,
getTriggerDomain,
states,
entities,
devices
)
: calculateActiveSystemDomains(
this._conditionDescriptions,
this._manifests,
getConditionDomain,
states,
entities,
devices
);
}
);
private _getAllItems = memoizeOne(getAllItems);
@@ -1,3 +1,4 @@
import { provide } from "@lit/context";
import { mdiAlertOutline, mdiHelpCircleOutline } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -33,6 +34,7 @@ import {
} from "../../../../../data/target";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
// when a condition has a `for:` duration, the recorder is only queried this far
@@ -47,6 +49,10 @@ const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
@customElement("ha-automation-condition-platform")
export class HaPlatformCondition extends LitElement {
// Triggers and conditions don't expand the groups their targets name.
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: PlatformCondition;
@@ -19,7 +19,7 @@ import {
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consume } from "../../../../common/decorators/consume";
import { ensureArray } from "../../../../common/array/ensure-array";
import { transform } from "../../../../common/decorators/transform";
@@ -34,6 +34,7 @@ import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/entity/state-badge";
import { showTargetDetailsDialog } from "../../../../components/target-picker/dialog/show-dialog-target-details";
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
import type { ConfigEntry } from "../../../../data/config_entries";
import {
apiContext,
@@ -55,8 +56,10 @@ import {
type TargetSelector,
} from "../../../../data/selector";
import {
extractFromTarget,
explainTarget,
targetEntitiesAfterExclude,
type HassServiceTargetWithExclude,
type TargetExplanation,
type TargetType,
} from "../../../../data/target";
import { showMoreInfoDialog } from "../../../../dialogs/more-info/show-ha-more-info-dialog";
@@ -81,6 +84,12 @@ export class HaAutomationRowTargets extends LitElement {
@property({ reflect: true })
public size: "s" | "m" = "m";
/**
* Whether the target expands the groups it names. Overrides what the
* context provides, for a place that shows targets of different kinds.
*/
@property({ attribute: false }) public expandGroup?: boolean;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -110,6 +119,11 @@ export class HaAutomationRowTargets extends LitElement {
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
// Whether the target expands the groups it names, like an action does.
@state()
@consume({ context: targetExpandGroupContext, subscribe: true })
private _expandGroup?: boolean;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@@ -118,29 +132,27 @@ export class HaAutomationRowTargets extends LitElement {
private _loadingCompositeSplits = false;
private _countCache = new Map<
string,
Promise<TargetCount | undefined> | TargetCount | undefined
>();
// What each target resolves to and what the exclude takes out, from core.
// `null` once core couldn't tell.
@state() private _explanation?: TargetExplanation | null;
// What the target's `exclude` resolves to, shared by every count.
private _excludedEntities?: Promise<Set<string>>;
private _explainRequest = 0;
// The same, once resolved, to mark excluded entity targets.
@state() private _excludedEntityIds = new Set<string>();
private _rerenderCount = true;
private get _resolvedExpandGroup(): boolean {
return this.expandGroup ?? this._expandGroup ?? true;
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (
changedProps.has("target") ||
changedProps.has("selector") ||
changedProps.has("interactive") ||
changedProps.has("expandGroup") ||
changedProps.has("_expandGroup") ||
changedProps.has("_registries")
) {
this._rerenderCount = true;
this._excludedEntities = undefined;
this._resolveExcludedEntityIds();
this._explain();
}
if (
@@ -181,11 +193,6 @@ export class HaAutomationRowTargets extends LitElement {
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
this._rerenderCount = false;
}
private _countMatchingEntities(referencedEntities: string[]): number {
const targetSelector = this.selector;
const hasEntityFilter = !!targetSelector?.target?.entity;
@@ -237,102 +244,94 @@ export class HaAutomationRowTargets extends LitElement {
: undefined;
}
private async _resolveExcludedEntityIds() {
if (!this._api || !this._getExclude()) {
this._excludedEntityIds = new Set();
// The target without its templated ids, which only resolve when the
// automation runs, so core can explain the rest.
private _explainableTarget(): HassServiceTargetWithExclude | undefined {
const { exclude: _exclude, ...target } = isConfigObject(this.target)
? this.target
: {};
const explainable: HassServiceTargetWithExclude = {};
Object.entries(target).forEach(([key, ids]) => {
const plain = ensureArray(ids).filter((id) => !isTemplate(id));
if (plain.length) {
explainable[key] = plain;
}
});
return Object.keys(explainable).length ? explainable : undefined;
}
// Only targets other than entities show a count, and only when the summary
// is interactive; without one of those, the explanation is only needed to
// tell which entity targets the exclude takes out again.
private async _explain() {
const request = ++this._explainRequest;
const target = this._explainableTarget();
const exclude = this._getExclude();
const counts =
this.interactive &&
!!target &&
Object.keys(target).some((key) => key !== "entity_id");
if (!this._api || !target || (!counts && !exclude)) {
this._explanation = undefined;
return;
}
const request = this._getExcludedEntities();
const excluded = await request;
// A newer target started its own lookup in the meantime.
if (request === this._excludedEntities) {
this._excludedEntityIds = excluded;
}
}
private _getExcludedEntities(): Promise<Set<string>> {
const exclude = this._getExclude();
if (!exclude) {
return Promise.resolve(new Set());
}
if (!this._excludedEntities) {
this._excludedEntities = extractFromTarget(
let explanation: TargetExplanation | null;
try {
explanation = await explainTarget(
this._api.callWS,
exclude,
false,
false
)
.then((result) => new Set(result.referenced_entities))
// Counts then leave the exclude out rather than failing altogether.
.catch(() => new Set<string>());
{ ...target, exclude },
this._resolvedExpandGroup,
this.selector?.target?.primary_entities_only
);
} catch (_err) {
explanation = null;
}
if (request === this._explainRequest) {
this._explanation = explanation;
}
return this._excludedEntities;
}
private _formatCount(count: TargetCount | undefined) {
if (!count) {
return " (-)";
// What each target is left with once the exclude is taken out of it.
private _remaining = memoizeOne(
(explanation: TargetExplanation | null | undefined) =>
explanation ? targetEntitiesAfterExclude(explanation) : undefined
);
// The entity targets the exclude block takes out again entirely.
private _excludedEntities = memoizeOne(
(explanation: TargetExplanation | null | undefined): Set<string> => {
const remaining = this._remaining(explanation);
return new Set(
explanation?.includes
.filter(
({ type, id, entities }) =>
type === "entity" &&
entities.length > 0 &&
remaining![`entity:${id}`].length === 0
)
.map(({ id }) => id)
);
}
return ` (${count.count})`;
);
private _isExcludedEntity(entityId: string): boolean {
return this._excludedEntities(this._explanation).has(entityId);
}
private _renderCount(
targetType: "floor" | "area" | "device" | "label",
targetId: string
) {
const key = `${targetType}:${targetId}`;
let fallback = " (-)";
if (!this._countCache.has(key) || this._rerenderCount) {
const cached = this._countCache.get(key);
if (cached && !(cached instanceof Promise)) {
fallback = this._formatCount(cached);
}
this._countCache.set(
key,
Promise.all([
extractFromTarget(
this._api.callWS,
{
[`${targetType}_id`]: [targetId],
},
false,
this.selector?.target?.primary_entities_only
),
this._getExcludedEntities(),
])
.then(([result, excluded]): TargetCount => {
// Excludes are global, so anything they resolve to is taken out
// of this target too.
const remaining = result.referenced_entities.filter(
(entityId) => !excluded.has(entityId)
);
return { count: this._countMatchingEntities(remaining) };
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error("Error counting target entities", err);
return undefined;
})
);
if (this._explanation === null) {
return nothing;
}
const cached = this._countCache.get(key);
if (cached instanceof Promise) {
return until(
cached.then((count) => {
if (this._countCache.get(key) === cached) {
this._countCache.set(key, count);
}
return count === undefined ? nothing : this._formatCount(count);
}),
fallback
);
const remaining = this._remaining(this._explanation)?.[
`${targetType}:${targetId}`
];
if (!remaining) {
return " (-)";
}
if (cached) {
return this._formatCount(cached);
}
return nothing;
return ` (${this._countMatchingEntities(remaining)})`;
}
protected render() {
@@ -368,7 +367,7 @@ export class HaAutomationRowTargets extends LitElement {
0
);
const excludedEntityTargets = ensureArray(target.entity_id ?? []).filter(
(entityId) => this._excludedEntityIds.has(entityId)
(entityId) => this._isExcludedEntity(entityId)
).length;
if (totalLength <= 5) {
@@ -553,9 +552,8 @@ export class HaAutomationRowTargets extends LitElement {
let badgeTargetId: string | undefined = targetId;
let badgeTargetType: string | undefined = targetType;
let countTemplate: unknown = nothing;
// An entity target that the exclude block takes out again.
const excluded =
targetType === "entity" && this._excludedEntityIds.has(targetId);
targetType === "entity" && this._isExcludedEntity(targetId);
if (targetType === "entity" && ["all", "none"].includes(targetId)) {
icon = mdiShape;
@@ -724,6 +722,7 @@ export class HaAutomationRowTargets extends LitElement {
itemId: targetId,
selector: this.selector,
exclude: this._getExclude(),
expandGroup: this._resolvedExpandGroup,
});
}
@@ -840,10 +839,6 @@ export class HaAutomationRowTargets extends LitElement {
`;
}
interface TargetCount {
count: number;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-targets": HaAutomationRowTargets;
@@ -1,3 +1,4 @@
import { provide } from "@lit/context";
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiArrowDown,
@@ -119,6 +120,7 @@ import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
export interface TriggerElement extends LitElement {
trigger: Trigger;
@@ -148,6 +150,10 @@ export const handleChangeEvent = (element: TriggerElement, ev: CustomEvent) => {
@customElement("ha-automation-trigger-row")
export default class HaAutomationTriggerRow extends LitElement {
// Triggers and conditions don't expand the groups their targets name.
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: Trigger;
@@ -1,3 +1,4 @@
import { provide } from "@lit/context";
import { mdiHelpCircleOutline } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -22,6 +23,7 @@ import {
} from "../../../../../data/trigger";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
field.selector &&
@@ -37,6 +39,10 @@ const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
@customElement("ha-automation-trigger-platform")
export class HaPlatformTrigger extends LitElement {
// Triggers and conditions don't expand the groups their targets name.
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: PlatformTrigger;
@@ -429,9 +429,7 @@ class HaBlueprintOverview extends LitElement {
></ha-icon-button>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize(
"ui.panel.config.blueprint.overview.delete_selected"
)}
.label=${this.hass.localize("ui.common.delete")}
.disabled=${this._usageCountsLoading || !this._selected.length}
@click=${this._removeSelected}
></ha-assist-chip>
+27 -1
View File
@@ -1,5 +1,7 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { replaceCurrentUrl } from "../../../common/navigate";
import { nextRender } from "../../../common/util/render-status";
import "../../../layouts/hass-subpage";
import "./ai-task-pref";
import "./mcp-pref";
@@ -11,6 +13,26 @@ class HaConfigSectionAI extends LitElement {
@property({ type: Boolean }) public narrow = false;
public connectedCallback(): void {
super.connectedCallback();
this._scrollToSection();
}
private async _scrollToSection() {
const hash = window.location.hash.substring(1);
if (!hash) {
return;
}
await nextRender();
// In the demo, the hash is the route of the page, not a section.
const section = this.shadowRoot!.getElementById(hash);
if (!section) {
return;
}
section.scrollIntoView();
replaceCurrentUrl(window.location.pathname);
}
protected render() {
return html`
<hass-subpage
@@ -24,7 +46,11 @@ class HaConfigSectionAI extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
></ai-task-pref>
<mcp-pref .hass=${this.hass} .narrow=${this.narrow}></mcp-pref>
<mcp-pref
id="mcp"
.hass=${this.hass}
.narrow=${this.narrow}
></mcp-pref>
</div>
</hass-subpage>
`;
@@ -1645,6 +1645,7 @@ export class HaConfigDevicePage extends LitElement {
css`
:host {
display: block;
height: 100%;
}
.container {
display: flex;
@@ -1923,8 +1923,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
margin-inline-start: 0;
}
ha-row-item.main-entity::part(supporting-text),
ha-row-item.device-area::part(supporting-text) {
ha-row-item::part(supporting-text),
ha-list-item-button::part(supporting-text) {
white-space: normal;
}
ha-row-item {
@@ -19,6 +19,7 @@ import type { HaLogViewer } from "../../../components/ha-log-viewer";
import "../../../components/ha-svg-icon";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import "../../../components/skeleton/ha-skeleton-log";
import {
connectionContext,
internationalizationContext,
@@ -51,6 +52,9 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
@state() private _live = false;
// The backend answers the subscription once it has read the backlog
@state() private _loading = false;
@state() private _search = "";
@state() private _wrapLines = true;
@@ -84,9 +88,11 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
(lines) => this._addLines(lines)
);
this._unsub = unsub;
this._loading = true;
unsub.then(
() => {
if (this._unsub === unsub) {
this._loading = false;
this._live = true;
}
},
@@ -95,6 +101,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
return;
}
this._unsub = undefined;
this._loading = false;
if (err?.code === ERR_UNKNOWN_COMMAND) {
this._unsupported = true;
} else if (
@@ -130,6 +137,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
// _subscribe reports subscription errors, so only ignore them here
this._unsub?.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
this._loading = false;
this._live = false;
}
@@ -209,12 +217,18 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
${
this._lineCount
? nothing
: html`<div class="waiting">
${this._i18n.localize(
"ui.panel.config.integrations.config_entry.live_log.waiting",
{ integration: this.params.name }
)}
</div>`
: this._loading
? html`<ha-skeleton-log
.label=${this._i18n.localize(
"ui.panel.config.logs.loading_log"
)}
></ha-skeleton-log>`
: html`<div class="waiting">
${this._i18n.localize(
"ui.panel.config.integrations.config_entry.live_log.waiting",
{ integration: this.params.name }
)}
</div>`
}
</ha-log-viewer>
${
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more