Compare commits

..
Author SHA1 Message Date
Bram Kragten 8f73718a35 Bumped version to 20260826.1 2026-08-27 17:07:22 +02:00
Paul BotteinandBram Kragten 4bf2acdb43 Show serial port info in a bottom sheet on mobile (#53839) 2026-08-27 17:06:48 +02:00
Shay RedmondandBram Kragten f622e5eeb9 Small copy improvements for Cloud feature list (#53833) 2026-08-27 17:06:47 +02:00
Paul BotteinandBram Kragten 595068597f Keep the row config when changing a device automation type (#53832) 2026-08-27 17:06:46 +02:00
Aidan TimsonandBram Kragten c708f35ab6 Fix MQTT subscribe controls layout (#53831) 2026-08-27 17:06:45 +02:00
Aidan TimsonandBram Kragten 205874c800 Show app icons for serial port consumers (#53827) 2026-08-27 17:06:44 +02:00
Petar Petrov 47ddcbe2e5 Fix editor state leaking when switching automations, scripts, or scenes (#53826)
* Fix editor state leaking when switching automations, scripts, or scenes

Recreate the page when the item id in the route changes so YAML mode,
undo history, and scene live state cannot carry over to the next item.

* Declare itemId on editor routes instead of guessing from the path

Path-shape matching remounted nested config routers, Lovelace, and
developer tools. Recreate only where edit/show routes opt in.

* Do not restore scene live states when the editor is torn down

A remounted editor starts in review; disconnect only needs to drop
the subscription, not undo device changes from live mode.

* Use the normal create path when remounting itemId pages

Clear the current page instead of calling _createPanel directly so
load and the loading screen still run. Drop the redundant cache delete.

* Fix dialog-form tests leaking focus restore after jsdom teardown

Nested submit/cancel schedules a nextRender callback that ran after
Vitest tore down HTMLElement, failing CI with an unhandled rejection.
2026-08-27 17:06:43 +02:00
Petar PetrovandBram Kragten c43b8bfbe0 Do not show empty Grid legend item on energy usage graph (#53822)
Do not add empty combined Grid series on energy usage graph
2026-08-27 17:06:41 +02:00
Bram KragtenandClaude Opus 5 e349b00b15 Replace CARTO raster tiles with OpenStreetMap vector tiles (#53816)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-27 17:06:40 +02:00
Paul BotteinandBram Kragten 0bb8bdc98a Clarify the replaced device state in pickers and target rows (#53807) 2026-08-27 17:06:39 +02:00
Petar PetrovandBram Kragten bbc5385843 Fix more-info dialog rewriting the URL after navigation (#53806) 2026-08-27 17:06:37 +02:00
Paul BotteinandBram Kragten 43dc5651d3 Keep the automation config when replacing a device in device automations (#53805) 2026-08-27 17:06:36 +02:00
Aidan TimsonandBram Kragten 18dcf7abc0 Refine entity details context section (#53797) 2026-08-27 17:06:35 +02:00
Bram Kragten a406abfdcd Merge branch 'rc' into dev 2026-08-26 14:48:07 +02:00
Bram Kragten fddc6e5506 Bumped version to 20260826.0 2026-08-26 14:47:19 +02:00
Petar PetrovandGitHub 69518334bb Keep the frame minimum when a gap marker shares the frame (#53580)
The chart data modules push a null y value to break the line where an
entity was unavailable. downSampleLineData read it with Number(), and
Number(null) is 0, which is not NaN, so the isNaN guard did not fire.
The marker then competed as a real value of 0 and won its frame's
minimum slot whenever the readings were positive, discarding the
frame's actual minimum and widening the rendered gap.

Keep markers out of the min/max comparisons entirely and hold at most
one per frame in its own slot. It is emitted, after the frame's values,
only when no kept value follows it: a marker followed by a value in its
own frame is a gap that closed within one frame, which is about one
device pixel wide and too narrow to show. That check runs per frame at
emit time, so the per-point path stays as it was. Keeping every marker
instead would blow up the output on series that are mostly null, such
as the climate heating dataset, which went from 823 to 14525 points
before this was bounded.

Skipping markers before the numeric work also makes gapped series
faster: 16% on a series with a few gaps, 27% on one that is mostly
gaps. Both now have benchmark coverage, which the gap path lacked.

Mean mode no longer averages markers in as zero.
2026-08-26 15:29:12 +03:00
Petar PetrovandGitHub 7a387004d6 Cache media source resolutions for view backgrounds (#53615)
Cache resolved media source URLs for view backgrounds
2026-08-26 15:28:34 +03:00
Paul BotteinandGitHub 1e9993785d Add reusable device class picker (#53798) 2026-08-26 13:26:32 +02:00
renovate[bot]andGitHub 1a3a93e3b9 Update dependency cropperjs to v1.6.3 (#53796) 2026-08-26 11:33:32 +01:00
Jan BouwhuisandGitHub c6dd741bb5 Add device class selector (#53672)
* Add sesnor device class selector

* Remove unused constants

* Remove stale test case and import

* Fix CI tests

* Add domain and make selector generic

* Follow up comments

* Use import
2026-08-26 12:23:26 +02:00
Krisjanis LejejsandGitHub 24f522b7ae Improve cloud login/sign up pages, add auto login (#53790)
* Improve cloud login/sign up pages, add auto login

* Improve trual button text

* Improve event subscription, fix email wrapping
2026-08-26 05:17:59 -04:00
Petar PetrovandGitHub 0594229a45 Fix choose-view dialog showing another dashboard's views (#53792)
* Show the selected dashboard's views in the choose-view dialog

* Block the choose-view action while the dashboard config loads
2026-08-26 12:08:06 +03:00
Josef ZweckandGitHub 615fd58324 Allow cross domain translations for config entry setup errors (#53791) 2026-08-26 09:35:48 +01:00
Bram Kragten 91c28c2f58 Bumped version to 20260729.7 2026-08-14 09:59:26 +02:00
Paul BotteinandBram Kragten 1f299d98e0 Fix clipped focus ring on media player playback buttons (#53632) 2026-08-14 09:58:33 +02:00
Petar PetrovandBram Kragten a74926aef6 Fix sensor card graph footers not receiving hass on lazy upgrade (#53576) 2026-08-14 09:58:32 +02:00
Bram Kragten 437a4cedf9 Check for wheel on PyPI simple index instead of JSON API (#53549) 2026-08-14 09:58:31 +02:00
Bram Kragten 55913d7505 Fix panels missing updates (#53548) 2026-08-14 09:58:30 +02:00
0bb654c70c Mock entity ID format WS commands in the demo (#53520)
Co-authored-by: Claude <[email protected]>
2026-08-14 09:58:30 +02:00
Bram Kragten 235541748b Bumped version to 20260729.6 2026-08-07 16:30:37 +02:00
Paul BotteinandBram Kragten 6bd1d698ce Fix disabled save button when leaving an automation with unsaved changes (#53538) 2026-08-07 16:27:35 +02:00
Bram Kragten 18862b868f Wait with showing pending http changes dialog until loading screen is… (#53534)
* Wait with showing pending http changes dialog until loading screen is gone

* fix timing
2026-08-07 16:27:33 +02:00
Bram Kragten 662d817a5e Fix link to different port being handled as internal link (#53530) 2026-08-07 16:27:32 +02:00
Aidan TimsonandBram Kragten 7412ab5578 Remove "dev" from tools actions description (#53528)
* Remove "dev" from tools actions description

* Clarify vt is browser developer tools
2026-08-07 16:27:31 +02:00
Krisjanis LejejsandBram Kragten b2be0bd673 Remove cloud demo controls (#53524) 2026-08-07 16:27:30 +02:00
Bram Kragten a141432546 Don't disable interaction when row is disabled (#53517)
dont disable interaction when row is disabled
2026-08-07 16:27:29 +02:00
Paul BotteinandBram Kragten cd50531b67 Stack the sidebar when only one column fits (#53515) 2026-08-07 16:27:29 +02:00
Petar PetrovandBram Kragten f7438f4d0e Accept hostnames and localize validation messages in the HTTP config form (#53511)
Accept hostnames in HTTP config listen addresses and localize number validation
2026-08-07 16:27:28 +02:00
Petar PetrovandBram Kragten a7fabb8a8c Only show the sidebar tab switcher when its tabs are labelled (#53508) 2026-08-07 16:26:46 +02:00
Aidan TimsonandBram Kragten 9dab55f39a Fix notification stacking (#53486)
* Stack notifications independently

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

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

---------

Co-authored-by: Petar Petrov <[email protected]>
(cherry picked from commit d03ba15c09)

# Conflicts:
#	test/panels/config/script/script-paste.test.ts
2026-08-07 16:23:38 +02:00
Bram Kragten 7ac98b7fdd Bumped version to 20260729.5 2026-08-05 09:56:45 +02:00
karwostsandBram Kragten 5c1c92e8ca Fix missing label in solar forecast dialog (#53506) 2026-08-05 09:56:31 +02:00
Paul BotteinandBram Kragten bf9765d557 Fix back navigation loop on the cloud page (#53497) 2026-08-05 09:56:30 +02:00
Petar PetrovandBram Kragten b013b1f929 Keep numeric slider and buttons within min and max when the step overshoots (#53489)
* Clamp numeric controls after snapping to the step

* Cover the pointer path, display and arrow keys at the bounds
2026-08-05 09:53:55 +02:00
Petar PetrovandBram Kragten 8026a59e70 Fix wrong month in date picker calendar header (#53453) 2026-08-05 09:50:37 +02:00
Bram Kragten ffa7cf17a6 Bumped version to 20260729.4 2026-08-04 13:19:17 +02:00
karwostsandBram Kragten 5a5cec9060 Fix slow loading of entity rows in device page (#53471)
Fix lazy loading of entity rows in device page
2026-08-04 12:50:36 +02:00
Petar PetrovandBram Kragten bb962ba3d8 Fix energy default period being saved under the wrong storage key (#53462) 2026-08-04 12:50:35 +02:00
d4a59ff1c7 Drop websocket collections created by an iframe panel on teardown (#53448)
* Drop websocket collections created by an iframe panel on teardown

`getCollection()` caches collections (entity registry, label registry, ...) on
the `Connection` object. A custom panel embedded in an iframe shares the
connection with the main window, so a collection the panel is the first to
request is created inside the iframe's realm.

When the user leaves the panel, `ha-panel-custom` removes the iframe and that
realm is destroyed - but the collection stays cached on the connection. Its
store, and the `setTimeout` that hands the cached state to new subscribers, are
gone, so every later subscriber waits forever for a callback that can never
fire. Since the registries moved to `LazyContextProvider` they are subscribed
on demand, which makes an iframe panel the first requester far more often: the
KNX panel's entity table renders empty after leaving and re-entering it, and
the main frontend can inherit the same dead cache.

Clean up from the iframe's own `pagehide` handler, while the realm is still
alive - `disconnectedCallback` on `ha-panel-custom` runs after the browsing
context is discarded, when `contentWindow` is already `null`.

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

* Use unknown instead of any in the CachedCollection shape

The helper never inspects the subscribed state, so the parameter type only
needs to be a placeholder.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-04 12:50:34 +02:00
karwostsandBram Kragten f496d842a6 Allow to favorite the manual card (#53445) 2026-08-04 12:50:33 +02:00
Petar PetrovandBram Kragten c14d07c845 Stop the statistics graph card editor looping while typing a name (#53434) 2026-08-04 12:50:32 +02:00
Petar PetrovandBram Kragten 8a1f2e86bd Show progress on the Zigbee backup button while the backup is created (#53422) 2026-08-04 12:49:55 +02:00
Bram Kragten 1454f4d081 Bumped version to 20260729.3 2026-07-31 16:44:13 +02:00
Paul BotteinandBram Kragten 1a47c326b2 Fix tile name truncated too early (#53416) 2026-07-31 16:43:50 +02:00
Aidan TimsonandBram Kragten d8e827e08b Fix profile theme link colour (#53415) 2026-07-31 16:43:49 +02:00
John G.andBram Kragten 301c907fa9 Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 16:43:48 +02:00
karwostsandBram Kragten 1ef3bc94d0 Add a few missing logbook csv fields (#53412) 2026-07-31 16:43:47 +02:00
Bram KragtenandSimon Lamon ba0367be2f use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <[email protected]>

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-07-31 16:43:46 +02:00
Bram Kragten c186a31056 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 16:43:45 +02:00
bf8c92c95e Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

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

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-07-31 16:43:44 +02:00
Bram Kragten 9743117abf Bumped version to 20260729.2 2026-07-30 23:27:41 +02:00
Petar PetrovandBram Kragten 5e012973b2 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:27:10 +02:00
Petar PetrovandBram Kragten ea659f1b33 Show which energy power statistic is missing (#53404) 2026-07-30 23:25:16 +02:00
539803cb5b Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-07-30 23:25:15 +02:00
karwostsandBram Kragten 944d3332d1 Fix schedule editor dirty tracking (#53401) 2026-07-30 23:25:14 +02:00
Petar PetrovandBram Kragten 4a267d160f Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 23:25:13 +02:00
7edb9f8164 Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 23:25:12 +02:00
Bram Kragten ff814167c6 Bumped version to 20260729.1 2026-07-30 10:41:32 +02:00
Paul BotteinandBram Kragten d08d8dde64 Show integration logo and name in the replace device dialog (#53384) 2026-07-30 10:41:12 +02:00
Bram Kragten 5da441ceed Strip empty strings from http config (#53377) 2026-07-30 10:41:11 +02:00
Paul BotteinandBram Kragten 22e646a68a Allow area card name to wrap to two lines in vertical layout (#53370)
* Allow area card name to wrap to two lines in vertical layout

* Align vertical tile icons in grid sections

* Cut single big word
2026-07-30 10:41:11 +02:00
d0ab20479f Tools > Template use WA Split Panel (#53021)
* Add Web Awesome Split Panel component

* Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox.

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-07-30 10:41:10 +02:00
104 changed files with 4986 additions and 2557 deletions
+3
View File
@@ -74,3 +74,6 @@ test/e2e/app/dist/
.serena
test/benchmarks/results/
# Downloaded map glyph and sprite archives
.map-assets/
+11 -6
View File
@@ -4,6 +4,7 @@ import fs from "fs-extra";
import gulp from "gulp";
import path from "path";
import paths from "../paths.cjs";
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
const npmPath = (...parts) =>
path.resolve(paths.root_dir, "node_modules", ...parts);
@@ -89,7 +90,7 @@ function copyQrScannerWorker(staticDir) {
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
}
function copyMapPanel(staticDir) {
async function copyMapPanel(staticDir) {
const staticPath = genStaticPath(staticDir);
copyFileDir(
npmPath("leaflet/dist/leaflet.css"),
@@ -103,6 +104,10 @@ function copyMapPanel(staticDir) {
npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/")
);
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
}
function copyZXingWasm(staticDir) {
@@ -139,7 +144,7 @@ gulp.task("copy-static-app", async () => {
copyMdiIcons(staticDir);
// Panel assets
copyMapPanel(staticDir);
await copyMapPanel(staticDir);
// Qr Scanner assets
copyZXingWasm(staticDir);
@@ -155,7 +160,7 @@ gulp.task("copy-static-demo", async () => {
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
copyPolyfills(paths.demo_output_static);
copyMapPanel(paths.demo_output_static);
await copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
copyLocaleData(paths.demo_output_static);
@@ -168,7 +173,7 @@ gulp.task("copy-static-cast", async () => {
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
copyPolyfills(paths.cast_output_static);
copyMapPanel(paths.cast_output_static);
await copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
copyLocaleData(paths.cast_output_static);
@@ -184,7 +189,7 @@ gulp.task("copy-static-gallery", async () => {
paths.gallery_output_root
);
copyMapPanel(paths.gallery_output_static);
await copyMapPanel(paths.gallery_output_static);
copyFonts(paths.gallery_output_static);
copyTranslations(paths.gallery_output_static);
copyLocaleData(paths.gallery_output_static);
@@ -215,7 +220,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
await copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
+1
View File
@@ -14,6 +14,7 @@ import "./gen-icons-json.js";
import "./gen-sensor-entity-constants.js";
import "./landing-page.js";
import "./locale-data.js";
import "./map-assets.js";
import "./rspack.js";
import "./service-worker.js";
import "./translations.js";
+217
View File
@@ -0,0 +1,217 @@
// Assembles the static assets of the vector base map - style, SDF glyphs and
// icon sprites - into /static/map/. vector.openstreetmap.org sets CORS headers
// on its tiles only, so these cannot be loaded from there by a browser.
//
// Glyphs and sprites come from pinned VersaTiles releases, cached locally and
// verified against a digest.
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { colorful, eclipse } from "@versatiles/style";
import fs from "fs-extra";
import gulp from "gulp";
import { extract } from "tar";
import paths from "../paths.cjs";
// The tile URL is deliberately never named here: the OSMF asks consumers to
// resolve it through the TileJSON so they can move the tiles without every
// client needing a release. https://operations.osmfoundation.org/policies/vector/
const TILEJSON_URL =
"https://vector.openstreetmap.org/shortbread_v1/tilejson.json";
const ASSET_PATH = "/static/map";
const ARCHIVES = {
fonts: {
url: "https://github.com/versatiles-org/versatiles-fonts/releases/download/v2.2.0/noto_sans.tar.gz",
sha256: "a2dac39f4096722bc420367ffd4a36687cce7229e8aa760bc12cf657072eea6b",
},
sprites: {
url: "https://github.com/versatiles-org/versatiles-style/releases/download/v5.13.1/sprites.tar.gz",
sha256: "efffd0ee4cb9591bd52f16ff5b269d9618c7dd1db159cd6511943965560ddea5",
},
};
// Rendered with a device font through `localIdeographFontFamily`, so these are
// never downloaded - and they are 90% of the Noto Sans SDF set.
const LOCAL_IDEOGRAPH_BLOCKS = [
[0x2e80, 0x9fff], // CJK radicals through CJK Unified Ideographs, incl. kana
[0xac00, 0xd7ff], // Hangul syllables
[0xf900, 0xfaff], // CJK compatibility ideographs
[0xfe30, 0xfe4f], // CJK compatibility forms
];
// Our styles use bold only for motorway shields, so Latin, Greek and Cyrillic
// cover every ref and the rest of bold - half the glyph set - is dropped.
// `assertBoldStaysOnRefs` guards the assumption.
const BOLD_MAX_CODEPOINT = 0x04ff;
const BOLD_TEXT_FIELD = "{ref}";
const cacheDir = path.resolve(paths.root_dir, ".map-assets");
const outputDir = path.resolve(paths.build_dir, "map");
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
// Downloads an archive into the cache, or reuses it when the digest matches.
const cachedArchive = async (name, { url, sha256: expected }) => {
const file = path.join(cacheDir, `${name}.tar.gz`);
if (await fs.pathExists(file)) {
if (sha256(await readFile(file)) === expected) {
return file;
}
console.warn("Cached map %s archive is stale, downloading again", name);
}
console.log("Downloading map %s from %s", name, url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download map ${name}: ${response.status} ${response.statusText}`
);
}
const body = Buffer.from(await response.arrayBuffer());
const digest = sha256(body);
if (digest !== expected) {
throw new Error(
`Digest mismatch for map ${name}: expected ${expected}, got ${digest}`
);
}
await fs.outputFile(file, body);
return file;
};
const glyphRange = (entryPath) => {
const match = /^(?<font>[^/]+)\/(?<start>\d+)-(?<end>\d+)\.pbf$/.exec(
entryPath
);
return match
? { font: match.groups.font, start: Number(match.groups.start) }
: undefined;
};
const keepGlyph = (entryPath) => {
const range = glyphRange(entryPath);
if (!range) {
return false;
}
if (range.font.endsWith("_bold") && range.start > BOLD_MAX_CODEPOINT) {
return false;
}
return !LOCAL_IDEOGRAPH_BLOCKS.some(
([from, to]) => range.start >= from && range.start <= to
);
};
const keepSprite = (entryPath) =>
/^basics\/sprites(@2x)?\.(json|png)$/.test(entryPath);
// `neutrino`, for one, sets country and state labels in bold - names in any
// script, which would turn to tofu. Fail the build rather than ship that.
const assertBoldStaysOnRefs = (name, style) => {
const offenders = style.layers
.filter((layer) =>
(layer.layout?.["text-font"] ?? []).some((font) => font.endsWith("_bold"))
)
.filter((layer) => layer.layout["text-field"] !== BOLD_TEXT_FIELD)
.map((layer) => layer.id);
if (offenders.length) {
throw new Error(
`Style "${name}" uses bold for ${offenders.join(", ")}, which can hold ` +
`names in any script. Raise BOLD_MAX_CODEPOINT to cover the full set ` +
`before shipping this style.`
);
}
};
// MapLibre extends the fetched TileJSON with the style's source options, so
// anything left here wins and freezes at build time. Dropping them is what lets
// a remote switch move the attribution and zoom range too, not just the URLs.
const TILEJSON_FIELDS = [
"tiles",
"attribution",
"bounds",
"minzoom",
"maxzoom",
"scheme",
];
// The builder can only write a tile URL, so the source is repointed afterwards.
// Keyed on there being exactly one source: any other shape means the builder's
// own default host would ship unnoticed.
const useTileJson = (name, style) => {
const sources = Object.values(style.sources);
if (sources.length !== 1) {
throw new Error(
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
`point at the TileJSON. Check what @versatiles/style emits.`
);
}
for (const field of TILEJSON_FIELDS) {
delete sources[0][field];
}
sources[0].url = TILEJSON_URL;
return style;
};
const styleOptions = {
// Keeps the generated URLs origin relative.
baseUrl: "",
glyphs: `${ASSET_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${ASSET_PATH}/sprites/basics/sprites` }],
};
const buildMapAssets = async () => {
const [fontArchive, spriteArchive] = await Promise.all([
cachedArchive("fonts", ARCHIVES.fonts),
cachedArchive("sprites", ARCHIVES.sprites),
]);
await fs.emptyDir(outputDir);
await Promise.all([
fs.ensureDir(path.join(outputDir, "fonts")),
fs.ensureDir(path.join(outputDir, "sprites")),
]);
await Promise.all([
extract({
file: fontArchive,
cwd: path.join(outputDir, "fonts"),
filter: keepGlyph,
}),
extract({
file: spriteArchive,
cwd: path.join(outputDir, "sprites"),
filter: keepSprite,
}),
// Both themes up front: dark is a real cartography, not an inverted raster.
...[
["light", colorful],
["dark", eclipse],
].map(([name, builder]) => {
const style = useTileJson(name, builder(styleOptions));
assertBoldStaysOnRefs(name, style);
return writeFile(
path.join(outputDir, `${name}.json`),
JSON.stringify(style)
);
}),
]);
};
// Shared so it does not have to be wired into every pipeline separately.
let pending;
export const ensureMapAssets = () => {
pending ??= buildMapAssets();
return pending;
};
gulp.task("build-map-assets", ensureMapAssets);
export const mapAssetsDir = outputDir;
+1
View File
@@ -19,6 +19,7 @@ const baseEntry = {
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
};
@@ -424,6 +424,23 @@ const SCHEMAS: {
},
},
},
device_class: {
name: "Device Class",
selector: {
device_class: {
domain: "sensor",
},
},
},
device_class_multiple: {
name: "Device Class (Multiple)",
selector: {
device_class: {
domain: "binary_sensor",
multiple: true,
},
},
},
select_custom: {
name: "Select (Custom)",
selector: {
@@ -39,6 +39,7 @@ const createConfigEntry = (
pref_disable_new_entities: false,
pref_disable_polling: false,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
...override,
-19
View File
@@ -16,25 +16,6 @@ const ENTITIES = [
duration: "0:05:00",
},
},
{
entity_id: "timer.active_timer",
state: "active",
attributes: {
friendly_name: "Active timer",
duration: "0:10:00",
remaining: "0:10:00",
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
},
},
{
entity_id: "timer.paused_timer",
state: "paused",
attributes: {
friendly_name: "Paused timer",
duration: "0:10:00",
remaining: "0:03:21",
},
},
];
@customElement("demo-more-info-timer")
+4 -1
View File
@@ -75,6 +75,7 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.5.0",
@@ -94,7 +95,7 @@
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.50.0",
"cropperjs": "1.6.2",
"cropperjs": "1.6.3",
"culori": "4.0.2",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
@@ -115,6 +116,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
@@ -170,6 +172,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@versatiles/style": "5.13.1",
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.0"
version = "20260826.1"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+1
View File
@@ -27,6 +27,7 @@ const ALLOWED_LICENSES = new Set([
"0BSD",
"CC0-1.0",
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
@@ -1,70 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HassEntity } from "home-assistant-js-websocket";
import { timerTimeRemaining } from "../../data/timer";
/**
* Tracks the live remaining time of a timer entity. While the timer is
* active, the host is re-rendered every second with an updated
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
*
* The host must call `setStateObj` whenever its timer entity changes.
*/
export class TimerRemainingTimeController implements ReactiveController {
public timeRemaining?: number;
private _host: ReactiveControllerHost;
private _stateObj?: HassEntity;
private _interval?: number;
constructor(host: ReactiveControllerHost) {
this._host = host;
host.addController(this);
}
public setStateObj(stateObj: HassEntity | undefined): void {
this._stateObj = stateObj;
this._startInterval();
}
public hostConnected(): void {
this._startInterval();
}
public hostDisconnected(): void {
this._clearInterval();
}
private _startInterval(): void {
this._clearInterval();
if (!this._stateObj) {
this.timeRemaining = undefined;
return;
}
this._calculateRemaining();
if (this._stateObj.state === "active") {
this._interval = window.setInterval(() => {
this._calculateRemaining();
this._host.requestUpdate();
}, 1000);
}
}
private _clearInterval(): void {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
private _calculateRemaining(): void {
this.timeRemaining = this._stateObj
? timerTimeRemaining(this._stateObj)
: undefined;
}
}
+30 -21
View File
@@ -1,13 +1,26 @@
import type { Map, TileLayer } from "leaflet";
import type { Map } from "leaflet";
import type { MapBaseLayer } from "../map/base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
export interface LeafletMapSetup {
map: Map;
leaflet: LeafletModuleType;
baseLayer: MapBaseLayer;
}
export const setupLeafletMap = async (
mapElement: HTMLElement,
initialView?: { latitude: number; longitude: number; zoom?: number }
): Promise<[Map, LeafletModuleType, TileLayer]> => {
initialView?: {
latitude: number;
longitude: number;
zoom?: number;
darkMode?: boolean;
}
): Promise<LeafletMapSetup> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
}
@@ -17,7 +30,11 @@ export const setupLeafletMap = async (
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement);
const map = Leaflet.map(mapElement, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet");
@@ -38,21 +55,13 @@ export const setupLeafletMap = async (
);
}
const tileLayer = createTileLayer(Leaflet).addTo(map);
return [map, Leaflet, tileLayer];
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
leaflet.Browser.retina ? "@2x.png" : ".png"
}`,
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: "abcd",
minZoom: 0,
maxZoom: 20,
}
// The base layer adds itself: the vector layer only builds its MapLibre map
// once it is on the map, and that failing has to fall back to raster.
const baseLayer = await createBaseLayer(
Leaflet,
map,
initialView?.darkMode ?? false
);
return { map, leaflet: Leaflet, baseLayer };
};
+230
View File
@@ -0,0 +1,230 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap } from "leaflet";
import type { StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
// endpoint sends CORS headers, so the style, glyphs and sprites are ours to
// serve - see build-scripts/gulp/map-assets.js. The credit comes from the
// TileJSON rather than from here, deliberately: it follows whoever serves the
// tiles.
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
// raster blocks a browser that sends no Referer, and the only referrer a browser
// can send is its origin, which identifies a Nabu Casa installation.
const RASTER_TILE_URL = "https://basemaps.cartocdn.com/rastertiles/voyager";
const CARTO_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
'&copy; <a href="https://carto.com/attributions">CARTO</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, so a dashboard
// full of map cards loses its first ones for good - nothing frees a slot for
// MapLibre to reclaim. A transient loss does get restored, hence the grace.
const CONTEXT_RESTORE_GRACE = 2000;
// On the map, not the layer: only Leaflet tile layers report their own limits,
// and marker clustering throws without a maximum. The floor is 1 because at
// Leaflet zoom 0 the adapter drives MapLibre to -1, outside its range.
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 20;
export interface MapBaseLayer {
// A no-op for raster, which has no dark variant and is inverted in CSS.
setDarkMode: (darkMode: boolean) => void;
}
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets, and blocklisted drivers.
const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
webGL2Supported = Boolean(context);
// Contexts are scarce; the probe must not keep one.
context?.getExtension("WEBGL_lose_context")?.loseContext();
} catch {
webGL2Supported = false;
}
}
return webGL2Supported;
};
// Asset URLs are stored origin relative so they follow the instance's host, but
// MapLibre rejects a relative sprite URL. The glyph URL is left alone: URL
// encoding would mangle its {fontstack} and {range} placeholders.
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (typeof style.sprite === "string") {
style.sprite = new URL(style.sprite, location.href).href;
} else if (Array.isArray(style.sprite)) {
style.sprite = style.sprite.map((sprite) => ({
...sprite,
url: new URL(sprite.url, location.href).href,
}));
}
return style;
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean
): Promise<MapBaseLayer | undefined> => {
let layer: ReturnType<typeof maplibreGL> | undefined;
try {
layer = createLayer({
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
// Draws CJK, kana and hangul with a device font, which is why we ship a
// tenth of the glyph set. No referrer is set: the endpoint serves without
// one, and the only one a browser can send identifies the installation.
localIdeographFontFamily: "sans-serif",
});
// The plugin builds the MapLibre map in `onAdd`, so a refused context, a
// blocked worker or a rejected blob URL throws here. Keep it inside the
// guard or those lose the raster fallback.
layer.addTo(map);
} catch {
if (layer) {
try {
layer.remove();
} catch {
// May never have finished being added.
}
}
return undefined;
}
// Tracked apart because a failed request must roll back to what is displayed,
// not to the opposite of what it asked for - with several in flight those are
// different, and guessing wrong makes the next toggle a permanent no-op.
let appliedDarkMode = darkMode;
let requestedDarkMode = darkMode;
// Styles are fetched, so only the newest request may touch the map.
let latestRequest = 0;
let vector = true;
const glMap = layer.getMaplibreMap();
let fallbackTimeout: number | undefined;
let contextLost = false;
// Declared first, but only ever called once all three exist.
const handleVisibilityChange = () => {
if (contextLost) {
scheduleSwap();
}
};
const swapToRaster = () => {
vector = false;
document.removeEventListener("visibilitychange", handleVisibilityChange);
try {
layer.remove();
} catch {
// Nothing left to detach.
}
createRasterLayer(leaflet, map);
};
const scheduleSwap = () => {
clearTimeout(fallbackTimeout);
// Backgrounding also drops the context, and there it comes back on return.
// Running the clock then would make switching apps enough to lose vector.
if (!vector || document.hidden) {
return;
}
fallbackTimeout = window.setTimeout(swapToRaster, CONTEXT_RESTORE_GRACE);
};
glMap.on("webglcontextlost", () => {
contextLost = true;
scheduleSwap();
});
glMap.on("webglcontextrestored", () => {
contextLost = false;
clearTimeout(fallbackTimeout);
});
document.addEventListener("visibilitychange", handleVisibilityChange);
map.on("unload", () => {
// Otherwise the timer revives a map that is already gone.
clearTimeout(fallbackTimeout);
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
return {
setDarkMode: (newDarkMode: boolean) => {
if (!vector || newDarkMode === requestedDarkMode) {
return;
}
requestedDarkMode = newDarkMode;
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
}
});
},
};
};
const createRasterLayer = (
leaflet: LeafletModuleType,
map: LeafletMap
): MapBaseLayer => {
leaflet
.tileLayer(
// These are the old retina tablets, and CARTO serves @2x - sharp tiles at
// the same request count, where `detectRetina` would fetch a zoom deeper
// at four times as many.
`${RASTER_TILE_URL}/{z}/{x}/{y}${leaflet.Browser.retina ? "@2x" : ""}.png`,
{
attribution: CARTO_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
}
)
.addTo(map);
return { setDarkMode: () => undefined };
};
export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const { maplibreGL: createLayer } =
await import("@maplibre/maplibre-gl-leaflet");
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
map,
darkMode
);
} catch {
// No chunk, no vector map - but still a map.
}
if (vectorLayer) {
return vectorLayer;
}
}
return createRasterLayer(leaflet, map);
};
+79 -18
View File
@@ -10,12 +10,16 @@ interface MeanFrame {
}
interface MinMaxFrame {
// A frame can hold a gap marker before any value lands in it, so the min/max
// slots below only mean something once this is true.
hasValue: boolean;
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
gapPoint: Point | undefined;
}
const SECOND = 1000;
@@ -49,6 +53,25 @@ function snapFrameSize(step: number): number {
return snapped;
}
// y is NaN for a frame seeded by a gap marker, which has no value yet.
function newFrame(
point: Point,
x: number,
y: number,
gapPoint: Point | undefined
): MinMaxFrame {
return {
hasValue: gapPoint === undefined,
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
gapPoint,
};
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -82,7 +105,10 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
const rawY = pointData[1] as number | null;
// Number(null) is 0, which would drag the mean towards zero
if (rawY === null) continue;
const y = Number(rawY);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
@@ -120,21 +146,34 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
if (isNaN(x)) continue;
const rawY = pointData[1] as number | null;
if (rawY === null) {
// The chart data modules push a null value to break the line where an
// entity was unavailable. Number(null) is 0, so such a marker must stay
// out of the comparisons below, where it would win the minimum slot
// whenever the readings are positive and discard the frame's real
// minimum. One marker per frame is enough to break the line, and keeping
// them all would blow up the output on series that are mostly null. The
// last one wins: where the break lands only depends on which points it
// sits between, not on its own x.
const gapIndex = Math.floor(x / step);
const gapFrame = frames.get(gapIndex);
if (gapFrame) {
gapFrame.gapPoint = point;
} else {
frames.set(gapIndex, newFrame(point, x, NaN, point));
}
continue;
}
const y = Number(rawY);
if (isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
frames.set(frameIndex, newFrame(point, x, y, undefined));
} else if (frame.hasValue) {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
@@ -147,18 +186,40 @@ export function downSampleLineData<
frame.maxX = x;
frame.maxY = y;
}
} else {
// the frame held nothing but a marker so far
frame.hasValue = true;
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
if (frame.hasValue) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
if (frame.gapPoint !== undefined) {
// A marker followed by a value in its own frame is a gap that closed
// within one frame, which is about one device pixel: too narrow to show.
// The kept points are exactly min and max, so comparing against the
// later of the two catches that without any work on the ingest path. A
// marker-only frame compares against its own x and always passes.
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
result.push(frame.gapPoint as T);
}
}
}
@@ -12,7 +12,6 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -180,12 +179,15 @@ export abstract class HaDeviceAutomationPicker<
(a, idx) => value === `${a.device_id}_${idx}`
);
const text = automation
const described =
automation ?? (this.value?.domain ? this.value : undefined);
const text = described
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
automation
described
)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
@@ -195,29 +197,24 @@ export abstract class HaDeviceAutomationPicker<
};
private async _updateDeviceInfo() {
// Asking a removed device for its automations fails rather than returning
// an empty list.
this._automations = this.deviceId
? (
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
await this._fetchDeviceAutomations(
this.hass.callWS,
this.deviceId
).catch(() => [] as T[])
).sort(sortDeviceAutomations)
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
// If there is no value, or if we have changed the device ID, reset the value.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
);
}
this._renderEmpty = true;
+71 -47
View File
@@ -1,4 +1,4 @@
import { mdiAlertOutline } from "@mdi/js";
import { mdiSwapHorizontal } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
@@ -22,6 +22,7 @@ import {
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
@@ -104,6 +105,14 @@ export class HaDevicePicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
/**
* The split devices that can actually replace the current value, when the
* caller knows better than this picker. Narrows the replacement candidates,
* so the user is only asked to choose when there is a real choice.
*/
@property({ attribute: false })
public replacementDeviceIds?: string[];
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@@ -187,7 +196,8 @@ export class HaDevicePicker extends LitElement {
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[]
items: (DevicePickerItem | string)[],
replacementDeviceIds: string[] | undefined
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
@@ -203,7 +213,11 @@ export class HaDevicePicker extends LitElement {
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
const candidates = split.split_ids.filter(
(id) =>
selectableIds.has(id) &&
(!replacementDeviceIds || replacementDeviceIds.includes(id))
);
return { candidates, primaryId: split.primary_id };
}
);
@@ -250,26 +264,26 @@ export class HaDevicePicker extends LitElement {
};
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
// The removed device has no name left to show, so say what happened
// to it instead. The alert below names the replacements. Without a
// replacement, fall back to the normal "not found" display.
if (isReplaced) {
return html`
<ha-svg-icon
slot="start"
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
.path=${mdiSwapHorizontal}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
<span slot="headline"
>${this.hass.localize(
"ui.components.device-picker.device_replaced"
)}</span
>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
@@ -399,31 +413,23 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
this._getItems(),
this.replacementDeviceIds
)
: undefined;
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
replacementName
canReplace
);
return html`
<ha-generic-picker
.noUnknownState=${canReplace}
.hass=${this.hass}
.autofocus=${this.autofocus}
.disabled=${this.disabled}
@@ -443,14 +449,9 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -464,30 +465,52 @@ export class HaDevicePicker extends LitElement {
}) {
const { candidates } = replacement;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
// The split devices all inherit the composite's name, so the integration is
// what tells them apart.
const replacementDevice =
candidates.length === 1 ? this.hass.devices[candidates[0]] : undefined;
const replacementName = replacementDevice
? computeDeviceName(replacementDevice)
: undefined;
const replacementDomain = replacementDevice?.primary_config_entry
? this._configEntryLookup[replacementDevice.primary_config_entry]?.domain
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName
replacementName && replacementDomain
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
"ui.components.device-picker.device_replaced_by_one_integration",
{
device: replacementName,
integration: domainToName(
this.hass.localize,
replacementDomain
),
}
)
: replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
variant="warning"
@click=${this._handleReplace}
>
${this.hass.localize("ui.components.device-picker.replace_device")}
${
candidates.length === 1
? this.hass.localize("ui.components.device-picker.replace_update")
: this.hass.localize("ui.components.device-picker.replace_choose")
}
</ha-button>
</ha-alert>
`;
@@ -498,7 +521,8 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
this._getItems(),
this.replacementDeviceIds
);
if (!replacement?.candidates.length) {
return;
+216
View File
@@ -0,0 +1,216 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
import { computeDeviceClassName } from "../data/entity/device_class";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
export const getDeviceClassOptions = (
domain: string,
localize: LocalizeFunc
): PickerComboBoxItem[] =>
(DOMAIN_DEVICE_CLASSES[domain] ?? []).map((deviceClass) => {
const primary = computeDeviceClassName(localize, domain, deviceClass);
return { id: deviceClass, primary, sorting_label: primary };
});
@customElement("ha-device-class-picker")
export class HaDeviceClassPicker extends LitElement {
@property() public domain?: string;
@property({ attribute: false }) public value?: string | string[];
@property({ type: Boolean }) public multiple = false;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
private _loadedDomain?: string;
protected willUpdate() {
if (!this.domain || !this._i18n || this._loadedDomain === this.domain) {
return;
}
this._loadedDomain = this.domain;
this._i18n.loadBackendTranslation("entity_component", this.domain);
}
private get _value(): string[] {
return this.value ? ensureArray(this.value) : [];
}
private _deviceClassName(deviceClass: string): string {
return this._i18n && this.domain
? computeDeviceClassName(this._i18n.localize, this.domain, deviceClass)
: deviceClass;
}
private _options = memoizeOne(
(
domain: string | undefined,
localize: LocalizeFunc | undefined
): PickerComboBoxItem[] =>
domain && localize ? getDeviceClassOptions(domain, localize) : []
);
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(this.domain, this._i18n?.localize);
return this.multiple
? this._availableOptions(options, this._value)
: options;
};
private _valueRenderer = (value: string) =>
html`<span slot="headline">${this._deviceClassName(value)}</span>`;
private _notFoundLabel = (search: string) => {
const term = html`<b>'${search}'</b>`;
return this._i18n
? this._i18n.localize("ui.components.device-class-picker.no_match", {
term,
})
: html`No device classes found for ${term}`;
};
protected render() {
const localize = this._i18n?.localize;
const emptyLabel = localize?.(
"ui.components.device-class-picker.no_device_classes"
);
if (this.multiple) {
const value = this._value;
return html`
${
value.length
? html`
<ha-chip-set>
${repeat(
value,
(deviceClass) => deviceClass,
(deviceClass) => {
const label = this._deviceClassName(deviceClass);
return html`
<ha-input-chip
.item=${deviceClass}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
<ha-generic-picker
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.addButtonLabel=${
this.label ?? localize?.("ui.components.device-class-picker.add")
}
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
}
return html`
<ha-generic-picker
.label=${
this.label ??
localize?.("ui.components.device-class-picker.device_class")
}
.value=${this.value as string | undefined}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || undefined });
}
private _itemAdded(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
const deviceClass = ev.detail.value;
if (!deviceClass || this._value.includes(deviceClass)) {
return;
}
this._setValue([...this._value, deviceClass]);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const deviceClass = (ev.currentTarget as HTMLElement & { item: string })
.item;
this._setValue(this._value.filter((item) => item !== deviceClass));
}
private _setValue(value: string[]) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
static styles = css`
:host {
display: block;
}
ha-generic-picker {
display: block;
width: 100%;
}
ha-chip-set {
padding: 8px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-class-picker": HaDeviceClassPicker;
}
}
+10 -1
View File
@@ -111,6 +111,11 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
// Skip the "unknown value" highlight and note for a value that is not in the
// list but that the value renderer presents on its own.
@property({ type: Boolean, attribute: "no-unknown-state" })
public noUnknownState = false;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -148,7 +153,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("value")) {
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState")
) {
this._setUnknownValue();
}
}
@@ -287,6 +295,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
this.value === null ||
@@ -0,0 +1,45 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DeviceClassSelector } from "../../data/selector";
import "../ha-device-class-picker";
@customElement("ha-selector-device_class")
export class HaDeviceClassSelector extends LitElement {
@property({ attribute: false }) public selector!: DeviceClassSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-device-class-picker
.domain=${this.selector.device_class?.domain}
.value=${this.value}
.multiple=${this.selector.device_class?.multiple ?? false}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-device-class-picker>
`;
}
static styles = css`
ha-device-class-picker {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-device_class": HaDeviceClassSelector;
}
}
@@ -30,6 +30,7 @@ const LOAD_ELEMENTS = {
date: () => import("./ha-selector-date"),
datetime: () => import("./ha-selector-datetime"),
device: () => import("./ha-selector-device"),
device_class: () => import("./ha-selector-device-class"),
duration: () => import("./ha-selector-duration"),
entity: () => import("./ha-selector-entity"),
entity_name: () => import("./ha-selector-entity-name"),
+32 -2
View File
@@ -25,6 +25,7 @@ import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
import type { MapBaseLayer } from "../../common/map/base-layer";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
@@ -160,6 +161,8 @@ export class HaMap extends ReactiveElement {
private Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _resizeObserver?: ResizeObserver;
private _mapItems: (Marker | Circle)[] = [];
@@ -211,6 +214,7 @@ export class HaMap extends ReactiveElement {
this.leafletMap.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
}
// the control went away with the map, so don't hold on to it
@@ -308,6 +312,7 @@ export class HaMap extends ReactiveElement {
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
this._baseLayer?.setDarkMode(this._darkMode);
}
private _loading = false;
@@ -322,11 +327,23 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
const setup = await setupLeafletMap(map, {
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
zoom: this.zoom,
darkMode: this._darkMode,
});
// Setting up fetches a style, so the element can be gone by now.
// `disconnectedCallback` had no map to tear down, and keeping this one
// would leave a live map - and its WebGL context - on a detached host,
// and its container too initialized to set up again on reconnect.
if (!this.isConnected) {
setup.map.remove();
return;
}
this.leafletMap = setup.map;
this.Leaflet = setup.leaflet;
this._baseLayer = setup.baseLayer;
this._updateMapStyle();
this.leafletMap.on("click", (ev) => {
if (this._clickCount === 0) {
@@ -891,9 +908,22 @@ export class HaMap extends ReactiveElement {
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
.leaflet-tile-pane {
/* Only the raster fallback is inverted for dark mode, the vector style
ships its own dark cartography. */
.leaflet-tile-pane .leaflet-tile {
filter: var(--map-filter);
}
/* The only two rules the MapLibre canvas needs from its stylesheet, the
rest of it styles controls and popups we do not render. */
.maplibregl-map {
position: relative;
overflow: hidden;
}
.maplibregl-canvas {
position: absolute;
top: 0;
left: 0;
}
.dark .leaflet-bar a {
background-color: #1c1c1c;
color: #ffffff;
@@ -7,6 +7,7 @@ import {
mdiHome,
mdiLabel,
mdiMinusBox,
mdiSwapHorizontal,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -177,36 +178,51 @@ export class HaTargetPickerItemRow extends LitElement {
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.stateObj=${
stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)
}
>
</ha-state-icon>
`
: nothing
: canMigrate
? html`<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.stateObj=${
stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)
}
>
</ha-state-icon>
`
: nothing
}
</div>
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
<span slot="headline"
>${
canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced_headline"
)
: name
}</span
>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
? replacement!.candidates.length === 1 && replacement!.name
? this.hass.localize(
"ui.components.target-picker.device_replaced_by_one",
{ device: replacement!.name }
)
: this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
@@ -256,7 +272,7 @@ export class HaTargetPickerItemRow extends LitElement {
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_device"
"ui.components.target-picker.replace_update"
)}
</ha-button>
`
+34
View File
@@ -480,6 +480,40 @@ export const migrateAutomationConfig = <
return config;
};
// The fields of the row holding a trigger, condition or action, as opposed to
// the configuration of its type. The UI editors of some types build their value
// from scratch, so these have to be carried over explicitly.
export const TRIGGER_ROW_CONFIG_KEYS = [
"alias",
"note",
"id",
"enabled",
"variables",
] as const;
export const CONDITION_ROW_CONFIG_KEYS = ["alias", "note", "enabled"] as const;
export const ACTION_ROW_CONFIG_KEYS = [
"alias",
"note",
"enabled",
"continue_on_error",
] as const;
export const pickRowConfig = <T extends object>(
row: T,
keys: readonly string[]
): Partial<T> => {
const source = row as Record<string, unknown>;
const config: Record<string, unknown> = {};
for (const key of keys) {
if (key in source) {
config[key] = source[key];
}
}
return config as Partial<T>;
};
export const migrateAutomationTrigger = (
trigger: Trigger | Trigger[],
report?: AutomationMigrationReport
+38 -5
View File
@@ -3,10 +3,16 @@ import type { HomeAssistant } from "../types";
type StrictConnectionMode = "disabled" | "guard_page" | "drop_connection";
export interface CloudAutoLogin {
email: string;
failed: string | null;
}
interface CloudStatusNotLoggedIn {
logged_in: false;
cloud: "disconnected" | "connecting" | "connected";
http_use_ssl: boolean;
auto_login: CloudAutoLogin | null;
}
export interface CertificateInformation {
@@ -102,15 +108,15 @@ export interface CloudLoginMFA extends CloudLoginBase {
code: string;
}
export type CloudEvent =
| { type: "login" | "logout" | "auto_login_cancelled" }
| { type: "auto_login_failed"; translation_key: string };
export const cloudLogin = ({
hass,
...rest
}: CloudLoginPassword | CloudLoginMFA) =>
hass.callApi<{ success: boolean; cloud_pipeline?: string }>(
"POST",
"cloud/login",
rest
);
hass.callApi<{ success: boolean }>("POST", "cloud/login", rest);
export const cloudLogout = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/logout");
@@ -130,6 +136,33 @@ export const cloudRegister = (
password,
});
export const cloudRegisterAutoLogin = (
hass: HomeAssistant,
email: string,
password: string
) =>
hass.callApi("POST", "cloud/register_auto_login", {
email,
password,
});
export const attemptCloudAutoLoginNow = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/attempt_auto_login_now" });
export const resendCloudAutoLoginConfirm = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/resend_auto_login_confirm" });
export const cancelCloudAutoLogin = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/cancel_auto_login" });
export const subscribeCloudEvents = (
hass: HomeAssistant,
callback: (event: CloudEvent) => void
) =>
hass.connection.subscribeMessage<CloudEvent>(callback, {
type: "cloud/subscribe_events",
});
export const cloudResendVerification = (hass: HomeAssistant, email: string) =>
hass.callApi("POST", "cloud/resend_confirm", {
email,
+1
View File
@@ -25,6 +25,7 @@ export interface ConfigEntry {
pref_disable_polling: boolean;
disabled_by: "user" | null;
reason: string | null;
error_reason_translation_domain: string | null;
error_reason_translation_key: string | null;
error_reason_translation_placeholders: Record<string, string> | null;
}
+58 -15
View File
@@ -182,26 +182,69 @@ export const deviceAutomationEditorMode = (
: "unknown-device";
};
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => Object.is(a[property], b[property]));
// An entity can be referenced by its registry id or by its entity id, and the
// two sides do not have to agree.
const deviceAutomationsSameEntity = (
entityRegistry: EntityRegistryEntry[],
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (typeof a !== typeof b) {
if (!a.entity_id && !b.entity_id) {
return true;
}
if (!a.entity_id || !b.entity_id) {
return false;
}
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
return (
a.entity_id === b.entity_id ||
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
);
};
// A device exposes the same automation type once per entity, so the same type
// on another entity is a different automation, not an equivalent one.
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
entityRegistry: EntityRegistryEntry[],
automations: T[],
automation: DeviceAutomation
): T | undefined =>
automations.find(
(candidate) =>
deviceAutomationsSameType(candidate, automation) &&
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
);
// Among the split devices that replaced a removed device, the ones that offer
// the given automation. Nothing in the registry says which of them took it over,
// so each candidate has to be asked.
export const fetchReplacementDevices = async <T extends DeviceAutomation>(
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
automation: DeviceAutomation,
compositeSplits: DeviceCompositeSplits,
fetchDeviceAutomations: (callWS: CallWS, deviceId: string) => Promise<T[]>
): Promise<string[]> => {
const candidates =
compositeSplits[automation.device_id]?.split_ids.filter(
(id) => id in hass.devices
) ?? [];
const automationsPerCandidate = await Promise.all(
candidates.map((id) =>
fetchDeviceAutomations(hass.callWS, id).catch(() => [] as T[])
)
);
return candidates.filter((_id, index) =>
findEquivalentDeviceAutomation(
entityRegistry,
automationsPerCandidate[index],
automation
)
);
};
const compareEntityIdWithEntityRegId = (
-64
View File
@@ -1,71 +1,7 @@
import type { LocalizeFunc } from "../../common/translations/localize";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../sensor_entity_constants";
export const NO_DEVICE_CLASS = "none";
// Mirrors core's `<Domain>DeviceClass` enums, until the backend exposes them.
export const DOMAIN_DEVICE_CLASSES: Record<string, readonly string[]> = {
binary_sensor: [
"battery",
"battery_charging",
"carbon_monoxide",
"cold",
"connectivity",
"door",
"garage_door",
"gas",
"heat",
"light",
"lock",
"moisture",
"motion",
"moving",
"occupancy",
"opening",
"plug",
"power",
"presence",
"problem",
"running",
"safety",
"smoke",
"sound",
"tamper",
"update",
"vibration",
"window",
],
button: ["identify", "restart", "update"],
cover: [
"awning",
"blind",
"curtain",
"damper",
"door",
"garage",
"gate",
"shade",
"shutter",
"window",
],
event: ["button", "doorbell", "motion"],
humidifier: ["dehumidifier", "humidifier"],
image_processing: ["alpr", "face", "ocr"],
infrared: ["emitter", "receiver"],
media_player: ["projector", "receiver", "speaker", "tv"],
number: SENSOR_NUMERIC_DEVICE_CLASSES,
sensor: [
...SENSOR_NUMERIC_DEVICE_CLASSES,
"date",
"enum",
"timestamp",
"uptime",
],
switch: ["outlet", "switch"],
update: ["firmware"],
valve: ["gas", "water"],
};
export const computeDeviceClassName = (
localize: LocalizeFunc,
domain: string,
+2 -1
View File
@@ -1,6 +1,7 @@
import { computeDomain } from "../../common/entity/compute_domain";
import type { HomeAssistant } from "../../types";
import { DOMAIN_DEVICE_CLASSES, NO_DEVICE_CLASS } from "./device_class";
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
import { NO_DEVICE_CLASS } from "./device_class";
const SEPARATOR = "/";
+19 -1
View File
@@ -1,3 +1,4 @@
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
@@ -7,7 +8,7 @@ export interface ResolvedMediaSource {
}
export const resolveMediaSource = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
media_content_id: string
) =>
hass.callWS<ResolvedMediaSource>({
@@ -15,6 +16,23 @@ export const resolveMediaSource = (
media_content_id,
});
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
// core). Resolving again returns a different signature, which would defeat the
// browser cache, so reuse the resolved URL for just under its validity.
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
export const resolveMediaSourceWithCache = (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
media_content_id: string
): Promise<ResolvedMediaSource> =>
timeCacheEntityPromiseFunc(
"_resolvedMediaSource",
RESOLVE_CACHE_TIME,
resolveMediaSource,
hass,
media_content_id
);
export const browseLocalMediaPlayer = (
hass: HomeAssistant,
mediaContentId?: string
+8
View File
@@ -48,6 +48,7 @@ export type Selector =
| DeviceSelector
| FloorSelector
| LegacyDeviceSelector
| DeviceClassSelector
| DurationSelector
| EntitySelector
| EntityNameSelector
@@ -484,6 +485,13 @@ export interface SelectSelector {
} | null;
}
export interface DeviceClassSelector {
device_class: {
domain: string;
multiple?: boolean;
} | null;
}
export interface SelectorSelector {
selector: {} | null;
}
+4 -43
View File
@@ -4,11 +4,8 @@ import type {
HassEntityBase,
} from "home-assistant-js-websocket";
import { createDurationData } from "../common/datetime/create_duration_data";
import durationToSeconds, {
durationDataToSeconds,
} from "../common/datetime/duration_to_seconds";
import durationToSeconds from "../common/datetime/duration_to_seconds";
import secondsToDuration from "../common/datetime/seconds_to_duration";
import type { FormatEntityStateFunc } from "../common/translations/entity-state";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
@@ -17,8 +14,6 @@ export type TimerEntity = HassEntityBase & {
duration: string;
remaining: string;
restore: boolean;
finishes_at?: string;
last_transition?: string;
};
};
@@ -69,18 +64,6 @@ export const deleteTimer = (hass: HomeAssistant, id: string) =>
timer_id: id,
});
// True when this state change is the timer completing: it ran out or
// timer.finish was called. Cancel also ends in "idle" but sets
// last_transition to "cancelled", so it does not match.
export const timerJustFinished = (
oldStateObj: HassEntity | undefined,
stateObj: HassEntity
): boolean =>
oldStateObj !== undefined &&
oldStateObj.state !== "idle" &&
stateObj.state === "idle" &&
stateObj.attributes.last_transition === "finished";
export const timerTimeRemaining = (
stateObj: HassEntity
): undefined | number => {
@@ -99,7 +82,7 @@ export const timerTimeRemaining = (
};
export const computeDisplayTimer = (
formatEntityState: FormatEntityStateFunc,
hass: HomeAssistant,
stateObj: HassEntity,
timeRemaining?: number
): string | null => {
@@ -108,40 +91,18 @@ export const computeDisplayTimer = (
}
if (stateObj.state === "idle" || timeRemaining === 0) {
return formatEntityState(stateObj);
return hass.formatEntityState(stateObj);
}
let display = secondsToDuration(timeRemaining || 0) || "0";
if (stateObj.state === "paused") {
display = `${display} (${formatEntityState(stateObj)})`;
display = `${display} (${hass.formatEntityState(stateObj)})`;
}
return display;
};
const leftPad = (num: number) => (num < 10 ? `0${num}` : `${num}`);
// Normalize duration data to whole-second hours/minutes/seconds fields, so
// out-of-range values ({seconds: 3600}) and fractional seconds cannot reach
// duration inputs or the serialized config. Timers only support whole seconds.
export const normalizeTimerDuration = (
data: HaDurationData
): HaDurationData => {
const totalSeconds = Math.floor(durationDataToSeconds(data));
return {
hours: Math.floor(totalSeconds / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
};
};
// Serialize duration data to the "H:MM:SS" format accepted by timer.start.
export const durationDataToTimerString = (data: HaDurationData): string => {
const { hours = 0, minutes = 0, seconds = 0 } = normalizeTimerDuration(data);
return `${hours}:${leftPad(minutes)}:${leftPad(seconds)}`;
};
// Prefill for the duration input: always the configured duration, independent
// of the live countdown. The field is meant to be edited, not to mirror the
// remaining time.
+8 -3
View File
@@ -230,13 +230,13 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams) {
if (!this.isConnected || !this._open || this._params !== expectedParams) {
return;
}
await this._waitForSelectorElements();
if (!this._open || this._params !== expectedParams) {
if (!this.isConnected || !this._open || this._params !== expectedParams) {
return;
}
@@ -250,7 +250,12 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams || !this._dialog) {
if (
!this.isConnected ||
!this._open ||
this._params !== expectedParams ||
!this._dialog
) {
return;
}
-1
View File
@@ -38,7 +38,6 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"siren",
"script",
"switch",
"timer",
"vacuum",
"valve",
"water_heater",
+91 -152
View File
@@ -2,8 +2,6 @@ import { consume } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { TimerRemainingTimeController } from "../../../common/controllers/timer-remaining-time-controller";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
@@ -11,20 +9,10 @@ import "../../../components/ha-button";
import type { HaButton } from "../../../components/ha-button";
import "../../../components/ha-duration-input";
import type { HaDurationData } from "../../../components/ha-duration-input";
import { apiContext, formattersContext } from "../../../data/context";
import { apiContext } from "../../../data/context";
import type { TimerEntity } from "../../../data/timer";
import {
computeDisplayTimer,
timerDurationData,
timerJustFinished,
} from "../../../data/timer";
import type {
HomeAssistantApi,
HomeAssistantFormatters,
ValueChangedEvent,
} from "../../../types";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
import { timerDurationData } from "../../../data/timer";
import type { HomeAssistantApi, ValueChangedEvent } from "../../../types";
@customElement("more-info-timer")
class MoreInfoTimer extends LitElement {
@@ -38,16 +26,8 @@ class MoreInfoTimer extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state() private _duration?: HaDurationData;
@state() private _finished = false;
private _remainingTime = new TimerRemainingTimeController(this);
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
// Seed the field once from the configured duration and keep it static,
@@ -55,117 +35,90 @@ class MoreInfoTimer extends LitElement {
if (this._duration === undefined && this.stateObj) {
this._duration = timerDurationData(this.stateObj);
}
if (changedProps.has("stateObj")) {
this._remainingTime.setStateObj(this.stateObj);
if (
this.stateObj &&
timerJustFinished(changedProps.get("stateObj"), this.stateObj)
) {
this._finished = true;
}
}
}
private _finishedAnimationEnded(): void {
this._finished = false;
}
protected render() {
if (!this._localize || !this._formatters || !this.stateObj) {
if (!this._localize || !this.stateObj) {
return nothing;
}
const timerState = this.stateObj.state;
return html`
<ha-more-info-state-header
class=${classMap({ finished: this._finished })}
.stateObj=${this.stateObj}
.stateOverride=${
computeDisplayTimer(
this._formatters.formatEntityState,
this.stateObj,
this._remainingTime.timeRemaining
) ?? undefined
<ha-duration-input
.data=${this._duration}
required
@value-changed=${this._durationChanged}
></ha-duration-input>
<div class="actions">
${
timerState === "idle"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.set")}
</ha-button>
`
: nothing
}
${
timerState === "active"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"pause"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.pause")}
</ha-button>
`
: nothing
}
${
timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"start"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"cancel"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.cancel")}
</ha-button>
<ha-button
appearance="plain"
size="s"
.action=${"finish"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.finish")}
</ha-button>
`
: nothing
}
@animationend=${this._finishedAnimationEnded}
></ha-more-info-state-header>
<div class="controls">
<ha-duration-input
.data=${this._duration}
required
@value-changed=${this._durationChanged}
></ha-duration-input>
<div class="actions">
${
timerState === "idle"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.set")}
</ha-button>
`
: nothing
}
${
timerState === "active"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"pause"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.pause")}
</ha-button>
`
: nothing
}
${
timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"start"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"cancel"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.cancel")}
</ha-button>
<ha-button
appearance="plain"
size="s"
.action=${"finish"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.finish")}
</ha-button>
`
: nothing
}
</div>
</div>
`;
}
@@ -195,34 +148,20 @@ class MoreInfoTimer extends LitElement {
});
}
static styles = [
moreInfoControlStyle,
css`
ha-duration-input {
display: flex;
justify-content: center;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
justify-content: center;
}
ha-more-info-state-header.finished {
animation: timer-finished-pulse 0.5s ease-in-out 2;
}
@keyframes timer-finished-pulse {
50% {
color: var(--error-color);
}
}
@media (prefers-reduced-motion: reduce) {
ha-more-info-state-header.finished {
animation-duration: var(--ha-animation-duration-none);
}
}
`,
];
static styles = css`
ha-duration-input {
display: flex;
justify-content: center;
margin: var(--ha-space-4) 0 var(--ha-space-2);
}
.actions {
margin: var(--ha-space-2) 0;
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
justify-content: center;
}
`;
}
declare global {
+208 -54
View File
@@ -1,9 +1,11 @@
import { mdiCheck, mdiContentCopy, mdiDevices, mdiTextureBox } from "@mdi/js";
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
@@ -11,8 +13,16 @@ import { getEntityContext } from "../../common/entity/context/get_entity_context
import checkValidDate from "../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import "../../components/ha-attribute-value";
import "../../components/ha-floor-icon";
import "../../components/ha-icon";
import "../../components/ha-icon-next";
import "../../components/ha-label";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import type { LocalizeKeys } from "../../common/translations/localize";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -25,6 +35,8 @@ import { getFeatures } from "../../common/entity/get_domain_features";
import { supportsFeature } from "../../common/entity/supports-feature";
import { titleCase } from "../../common/string/title-case";
import { stringCompare } from "../../common/string/compare";
import { brandsUrl } from "../../util/brands-url";
import { showToast } from "../../util/toast";
interface DetailsViewParams {
entityId: string;
@@ -33,7 +45,10 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
displayValue?: TemplateResult;
href?: string;
icon?: TemplateResult;
copyable?: boolean;
}
@customElement("ha-more-info-details")
@@ -52,6 +67,17 @@ class HaMoreInfoDetails extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _copiedValue?: string;
private _copyFeedbackTimeout?: number;
public disconnectedCallback(): void {
super.disconnectedCallback();
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = undefined;
this._copiedValue = undefined;
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("entry") && this.entry) {
@@ -93,12 +119,12 @@ class HaMoreInfoDetails extends LitElement {
? this.hass.localize(`component.${this.entry.platform}.title`) ||
this.entry.platform
: undefined;
const labelNames =
this.entry?.labels.map(
(labelId) =>
this._labels?.find((label) => label.label_id === labelId)?.name ??
labelId
) ?? [];
const labels =
this.entry?.labels.map((labelId) => ({
id: labelId,
entry: this._labels?.find((label) => label.label_id === labelId),
})) ?? [];
const labelNames = labels.map(({ id, entry }) => entry?.name ?? id);
const contextEntries: DetailEntry[] = [];
if (floor && floorName) {
@@ -106,6 +132,7 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.dialogs.more_info_control.floor",
value: floorName,
href: "/config/areas/dashboard",
icon: html`<ha-floor-icon slot="end" .floor=${floor}></ha-floor-icon>`,
});
}
if (area && areaName) {
@@ -113,6 +140,9 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.area",
value: areaName,
href: `/config/areas/area/${area.area_id}`,
icon: area.icon
? html`<ha-icon slot="end" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
});
}
if (device && deviceName) {
@@ -120,6 +150,7 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.device",
value: deviceName,
href: `/config/devices/device/${device.id}`,
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
});
}
if (this.entry?.platform && integrationName) {
@@ -129,31 +160,63 @@ class HaMoreInfoDetails extends LitElement {
href: this.entry.config_entry_id
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
: undefined,
icon: html`<img
slot="end"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: this.entry.platform,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`,
});
}
const entityEntries: DetailEntry[] = [
contextEntries.push(
{
translationKey: "ui.dialogs.more_info_control.entity_id",
value: this.params.entityId,
copyable: true,
},
{
translationKey: "ui.dialogs.more_info_control.labels",
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
},
];
displayValue: labels.length
? html`<div class="labels">
${labels.map(
({ id, entry }) => html`
<ha-label
class="text-ellipsis"
.color=${entry?.color ?? undefined}
.description=${entry?.description ?? undefined}
>
${
entry?.icon
? html`<ha-icon
slot="icon"
.icon=${entry.icon}
></ha-icon>`
: nothing
}
${entry?.name ?? id}
</ha-label>
`
)}
</div>`
: undefined,
}
);
const yamlData = {
...(contextEntries.length
? {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
},
}
: {}),
entity: {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
entity_id: this.params.entityId,
labels: labelNames,
},
@@ -171,17 +234,9 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
${
contextEntries.length
? html`<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>`
: nothing
}
<ha-grouped-list>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
@@ -191,14 +246,6 @@ class HaMoreInfoDetails extends LitElement {
${this._renderEntries(stateEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.entity"
)}
>
${this._renderEntries(entityEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
@@ -284,17 +331,72 @@ class HaMoreInfoDetails extends LitElement {
}
private _renderEntries(entries: DetailEntry[]) {
return entries.map(
(entry) => html`
<ha-list-item-value .label=${this.hass.localize(entry.translationKey)}>
${
entry.href
? html`<a href=${entry.href}>${entry.value}</a>`
: entry.value
}
</ha-list-item-value>
`
);
return entries.map((entry) => {
const label = this.hass.localize(entry.translationKey);
if (!entry.href && !entry.copyable) {
return html`
<ha-list-item-value .label=${label}
>${entry.displayValue ?? entry.value}</ha-list-item-value
>
`;
}
if (entry.copyable) {
return html`
<ha-list-item-button
aria-label=${this.hass.localize(
"ui.dialogs.more_info_control.copy_value",
{ label, value: entry.value }
)}
data-value=${entry.value}
@click=${this._copyValue}
>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
<ha-svg-icon
class=${this._copiedValue === entry.value ? "copy-success" : ""}
slot="end"
.path=${
this._copiedValue === entry.value ? mdiCheck : mdiContentCopy
}
></ha-svg-icon>
</ha-list-item-button>
`;
}
return html`
<ha-list-item-button .href=${entry.href}>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
${entry.icon ?? nothing}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
});
}
private async _copyValue(ev: HASSDomCurrentTargetEvent<HaListItemButton>) {
const value = ev.currentTarget.dataset.value;
if (value === undefined) {
return;
}
await copyToClipboard(value);
const duration = 4000;
this._copiedValue = value;
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = window.setTimeout(() => {
this._copiedValue = undefined;
this._copyFeedbackTimeout = undefined;
}, duration);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
duration,
});
}
private _renderAttributes(attributes: { name: string; label: string }[]) {
@@ -358,11 +460,63 @@ class HaMoreInfoDetails extends LitElement {
}
ha-grouped-list + ha-grouped-list {
margin-top: var(--ha-space-4);
margin-top: var(--ha-space-6);
}
a {
color: var(--primary-color);
ha-list-item-button {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
--ha-row-item-gap: var(--ha-space-3);
--mdc-icon-size: 20px;
}
ha-list-item-button::part(end) {
gap: var(--ha-space-2);
}
ha-list-item-button img {
width: 20px;
height: 20px;
object-fit: contain;
}
.link-row {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--ha-space-3);
}
.link-row .label {
flex: 1;
color: var(--secondary-text-color);
}
.link-row .value {
max-width: 60%;
min-width: 0;
text-align: end;
overflow-wrap: anywhere;
}
.labels {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--ha-space-1);
}
.labels ha-label {
min-width: 0;
max-width: 100%;
}
ha-icon-next {
color: var(--secondary-text-color);
}
ha-svg-icon.copy-success {
color: var(--success-color);
}
.empty {
+12 -2
View File
@@ -28,6 +28,7 @@ import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { mainWindow } from "../../common/dom/get_main_window";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
@@ -47,7 +48,10 @@ import {
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
import {
createMoreInfoUrl,
decodeMoreInfoUrl,
} from "../../common/url/more-info-query-params";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -231,7 +235,13 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
if (this._returnUrl) {
// Restore the pre-dialog URL only while the URL still carries this
// dialog's deep-link params: navigate() waits for the close only up to
// DIALOG_WAIT_TIMEOUT and may have committed a new URL already.
if (
this._returnUrl &&
decodeMoreInfoUrl(mainWindow.location.search).entityId === this._entityId
) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
+22 -4
View File
@@ -24,6 +24,8 @@ export interface RouteOptions {
// Function to load the page.
load?: () => Promise<unknown>;
cache?: boolean;
// Recreate the page when the remaining path (the item id) changes.
itemId?: boolean;
waitForReady?: boolean;
}
@@ -138,10 +140,23 @@ export class HassRouterPage extends ReactiveElement {
}
if (this._currentPage === newPage) {
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
const oldRoute = changedProps.get("route");
const oldTail = oldRoute ? computeRouteTail(oldRoute).path : undefined;
const newTail = route ? this._computeTail(route).path : undefined;
if (
typeof routeOptions === "object" &&
routeOptions.itemId &&
oldTail !== newTail
) {
// Fall through to the normal create path so `load` / loading screen
// still run. itemId pages are not cached, so this is a new element.
this._currentPage = "";
} else {
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
}
return;
}
if (!routeOptions) {
@@ -365,7 +380,10 @@ export class HassRouterPage extends ReactiveElement {
this.updatePageEl(panelEl);
this.appendChild(panelEl);
if (routerOptions.cacheAll || routeOptions.cache) {
if (
(routerOptions.cacheAll || routeOptions.cache) &&
!routeOptions.itemId
) {
this._cache[page] = panelEl;
}
}
@@ -3,6 +3,10 @@ import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
ACTION_ROW_CONFIG_KEYS,
pickRowConfig,
} from "../../../../data/automation";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
@@ -107,9 +111,8 @@ export default class HaAutomationActionEditor extends LitElement {
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.action.alias ? { alias: this.action.alias } : {}),
...(this.action.note ? { note: this.action.note } : {}),
...ev.detail.value,
...pickRowConfig(this.action, ACTION_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -14,6 +14,8 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceActions,
deviceAutomationsEqual,
fetchDeviceActionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -40,6 +42,8 @@ export class HaDeviceAction extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -95,13 +99,27 @@ export class HaDeviceAction extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.action,
compositeSplits,
fetchDeviceActions
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -115,6 +133,7 @@ export class HaDeviceAction extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.disabled=${this.disabled}
@value-changed=${this._devicePicked}
.hass=${this.hass}
@@ -156,6 +175,19 @@ export class HaDeviceAction extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("action");
if (previous && previous.device_id !== this.action.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -185,6 +217,15 @@ export class HaDeviceAction extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.action, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -7,7 +7,11 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { Condition } from "../../../../data/automation";
import { expandConditionWithShorthand } from "../../../../data/automation";
import {
CONDITION_ROW_CONFIG_KEYS,
expandConditionWithShorthand,
pickRowConfig,
} from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
@@ -125,9 +129,8 @@ export default class HaAutomationConditionEditor extends LitElement {
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.condition.alias ? { alias: this.condition.alias } : {}),
...(this.condition.note ? { note: this.condition.note } : {}),
...ev.detail.value,
...pickRowConfig(this.condition, CONDITION_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -14,6 +14,8 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceConditions,
deviceAutomationsEqual,
fetchDeviceConditionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -40,6 +42,8 @@ export class HaDeviceCondition extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -96,13 +100,27 @@ export class HaDeviceCondition extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.condition,
compositeSplits,
fetchDeviceConditions
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -116,6 +134,7 @@ export class HaDeviceCondition extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -157,6 +176,19 @@ export class HaDeviceCondition extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("condition");
if (previous && previous.device_id !== this.condition.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -187,6 +219,15 @@ export class HaDeviceCondition extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.condition, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -278,6 +278,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
const domain = hooks.domain;
try {
const config = await hooks.fetchFileConfig(this.hass, id);
if (!this.isConnected) {
return;
}
this.readOnly = false;
const report: AutomationMigrationReport = { deprecated: false };
this.config = hooks.normalizeConfig(config, report);
@@ -294,6 +297,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
);
hooks.checkValidation();
} catch (err: any) {
if (!this.isConnected) {
return;
}
if (err.status_code !== 404) {
const alertText =
err.body?.message || err.body || err.error || "Unknown error";
@@ -47,9 +47,11 @@ class HaConfigAutomation extends HassRouterPage {
},
edit: {
tag: "ha-automation-editor",
itemId: true,
},
show: {
tag: "ha-automation-editor",
itemId: true,
},
trace: {
tag: "ha-automation-trace",
@@ -7,6 +7,7 @@ import {
mdiFormatListBulleted,
mdiMenuDown,
mdiShape,
mdiSwapHorizontal,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import {
@@ -321,14 +322,25 @@ export class HaAutomationRowTargets extends LitElement {
let lastTargetType: string | null = null;
// The collapsed summary hides the individual targets, so carry over the
// warning when any of them no longer exists.
const hasMissingTarget = rows.some(
([targetType, targetId]) => !this._checkTargetExists(targetType, targetId)
);
return html`
<ha-dropdown
@wa-select=${this._handleTargetSelect}
@click=${stopPropagation}
@keydown=${stopPropagation}
>
<button slot="trigger" class="target">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<button
slot="trigger"
class=${classMap({ target: true, warning: hasMissingTarget })}
>
<ha-svg-icon
.path=${hasMissingTarget ? mdiAlert : mdiFormatListBulleted}
></ha-svg-icon>
<div class="label">
${this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.targets",
@@ -467,9 +479,15 @@ export class HaAutomationRowTargets extends LitElement {
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
if (targetType === "device" && this._compositeSplits?.[targetId]) {
if (
targetType === "device" &&
this._compositeSplits?.[targetId]?.split_ids.some(
(id) => id in this._registries.devices
)
) {
// The device was replaced by one or more split devices; make clear
// this reference needs to be updated, distinct from "unknown device".
icon = mdiSwapHorizontal;
label = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced"
);
@@ -686,6 +704,9 @@ export class HaAutomationRowTargets extends LitElement {
background-color: var(--ha-color-fill-warning-quiet-resting);
color: var(--ha-color-on-warning-normal);
}
ha-dropdown-item.warning ha-svg-icon {
color: var(--ha-color-on-warning-normal);
}
ha-dropdown-item.warning:hover {
background-color: var(--ha-color-fill-warning-quiet-hover);
color: var(--ha-color-on-warning-normal);
@@ -8,7 +8,11 @@ import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import "../../../../components/input/ha-input";
import type { Trigger } from "../../../../data/automation";
import { migrateAutomationTrigger } from "../../../../data/automation";
import {
TRIGGER_ROW_CONFIG_KEYS,
migrateAutomationTrigger,
pickRowConfig,
} from "../../../../data/automation";
import type { TriggerDescription } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
@@ -144,9 +148,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
if (isTriggerList(this.trigger)) return;
ev.stopPropagation();
const value = {
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
...(this.trigger.note ? { note: this.trigger.note } : {}),
...ev.detail.value,
...pickRowConfig(this.trigger, TRIGGER_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -16,6 +16,8 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceTriggers,
deviceAutomationsEqual,
fetchDeviceTriggerCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -42,6 +44,8 @@ export class HaDeviceTrigger extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -100,13 +104,27 @@ export class HaDeviceTrigger extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.trigger,
compositeSplits,
fetchDeviceTriggers
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -120,6 +138,7 @@ export class HaDeviceTrigger extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -161,6 +180,19 @@ export class HaDeviceTrigger extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("trigger");
if (previous && previous.device_id !== this.trigger.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -208,6 +240,15 @@ export class HaDeviceTrigger extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.trigger, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -225,9 +266,6 @@ export class HaDeviceTrigger extends LitElement {
) {
trigger = this._origTrigger;
}
if (this.trigger.id) {
trigger.id = this.trigger.id;
}
fireEvent(this, "value-changed", { value: trigger });
}
@@ -9,6 +9,7 @@ import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformTrigger } from "../../../../../data/automation";
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
@@ -27,15 +28,11 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
!("boolean" in field.selector && field.default);
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
...TRIGGER_ROW_CONFIG_KEYS,
"trigger",
"target",
"alias",
"note",
"id",
"variables",
"enabled",
"options",
] as const;
];
@customElement("ha-automation-trigger-platform")
export class HaPlatformTrigger extends LitElement {
@@ -21,14 +21,10 @@ import {
completeCloudOnboarding,
fetchCloudSubscriptionInfo,
ONBOARDING_ITEMS,
removeCloudData,
} from "../../../../data/cloud";
import type { Webhook } from "../../../../data/webhook";
import { fetchWebhooks } from "../../../../data/webhook";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../resources/styles";
@@ -41,6 +37,7 @@ import {
} from "./cloud-account-status";
import { showCloudOnboardingDialog } from "./show-dialog-cloud-onboarding";
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
import { confirmDeleteCloudData } from "../delete-cloud-data";
@customElement("cloud-account")
export class CloudAccount extends SubscribeMixin(LitElement) {
@@ -297,33 +294,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
}
private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_text"
),
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await cloudLogout(this.hass);
await removeCloudData(this.hass);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_failed"
),
text: err?.message,
});
return;
} finally {
fireEvent(this, "ha-refresh-cloud-status");
}
await confirmDeleteCloudData(this, this.hass, { signOutFirst: true });
}
private async _downloadSupportPackage() {
@@ -0,0 +1,63 @@
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import { showSupportPackageDialog } from "./account/show-dialog-cloud-support-package";
import { confirmDeleteCloudData } from "./delete-cloud-data";
// Recovery actions for the signed-out cloud pages, which all need to reach them
// — including while a registration is waiting on its email confirmation.
@customElement("cloud-signed-out-menu")
export class CloudSignedOutMenu extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
protected render(): TemplateResult {
return html`
<ha-dropdown @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="reset">
${this.hass.localize("ui.panel.config.cloud.account.reset_cloud_data")}
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="download">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
`;
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
switch (ev.detail.item.value) {
case "reset":
this._deleteCloudData();
break;
case "download":
showSupportPackageDialog(this);
break;
}
}
private async _deleteCloudData() {
await confirmDeleteCloudData(this, this.hass);
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-signed-out-menu": CloudSignedOutMenu;
}
}
@@ -0,0 +1,24 @@
import { css } from "lit";
export const cloudSignedOutStyle = css`
.content {
box-sizing: border-box;
display: flex;
flex-direction: column;
padding-bottom: calc(var(--safe-area-inset-bottom) + var(--ha-space-6));
}
ha-card {
width: 100%;
margin-bottom: 0;
}
h2 {
margin: 0;
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
white-space: normal;
overflow: visible;
text-overflow: clip;
text-wrap: balance;
}
`;
@@ -0,0 +1,48 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { cloudLogout, removeCloudData } from "../../../data/cloud";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
// Confirms, then wipes every trace of the cloud account from this instance.
// `signOutFirst` is for callers reached while signed in: the session has to go
// before the data can.
export const confirmDeleteCloudData = async (
element: HTMLElement,
hass: HomeAssistant,
{ signOutFirst = false }: { signOutFirst?: boolean } = {}
): Promise<boolean> => {
const confirm = await showConfirmationDialog(element, {
title: hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_title"
),
text: hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_text"
),
confirmText: hass.localize("ui.panel.config.cloud.account.reset"),
destructive: true,
});
if (!confirm) {
return false;
}
try {
if (signOutFirst) {
await cloudLogout(hass);
}
await removeCloudData(hass);
} catch (err: any) {
showAlertDialog(element, {
title: hass.localize("ui.panel.config.cloud.account.reset_data_failed"),
text: err?.message,
});
return false;
} finally {
fireEvent(element, "ha-refresh-cloud-status");
}
return true;
};
@@ -169,4 +169,8 @@ declare global {
interface HTMLElementTagNameMap {
"cloud-forgot-password-card": CloudForgotPasswordCard;
}
interface HASSDomEvents {
"cloud-done": { flashMessage: string };
}
}
@@ -21,7 +21,7 @@ export class CloudForgotPassword extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
back-path="/config/cloud/login"
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.title"
)}
+58 -5
View File
@@ -1,12 +1,14 @@
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import type { CloudStatus } from "../../../data/cloud";
import { cancelCloudAutoLogin } from "../../../data/cloud";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import type { ValueChangedEvent, HomeAssistant, Route } from "../../../types";
import "./account/cloud-account";
import "./login/cloud-login-panel";
import "./start/cloud-start";
const LOGGED_IN_URLS = [
"account",
@@ -18,7 +20,12 @@ const LOGGED_IN_URLS = [
"webhooks",
] as const;
const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"] as const;
const NOT_LOGGED_IN_URLS = [
"start",
"login",
"register",
"forgot-password",
] as const;
type CloudPage =
(typeof LOGGED_IN_URLS)[number] | (typeof NOT_LOGGED_IN_URLS)[number];
@@ -36,7 +43,7 @@ class HaConfigCloud extends HassRouterPage {
@property({ attribute: false }) public cloudStatus!: CloudStatus;
protected routerOptions: RouterOptions = {
defaultPage: "login",
defaultPage: "start",
showLoading: true,
initialLoad: () => this._cloudStatusLoaded,
// Guard the different pages based on if we're logged in.
@@ -45,14 +52,30 @@ class HaConfigCloud extends HassRouterPage {
if (!LOGGED_IN_URLS.some((url) => url === page)) {
return "account";
}
} else if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
return "login";
return undefined;
}
if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
return "start";
}
if (
page !== "register" &&
this.cloudStatus.auto_login &&
!this._autoLoginCancelled
) {
return "register";
}
return undefined;
},
routes: {
start: {
tag: "cloud-start",
},
login: {
tag: "cloud-login-panel",
load: () => import("./login/cloud-login-panel"),
},
register: {
tag: "cloud-register",
@@ -96,6 +119,10 @@ class HaConfigCloud extends HassRouterPage {
@state() private _loginEmail = "";
private _autoLoginCancelled = false;
private _lastPage = "";
private _resolveCloudStatusLoaded!: () => void;
private _cloudStatusLoaded = new Promise<void>((resolve) => {
@@ -122,6 +149,14 @@ class HaConfigCloud extends HassRouterPage {
navigate(this.route.prefix, { replace: true });
}
}
// A flash belongs to the page it was routed to. Cleared here rather than in
// beforeRender, which runs inside update() where assigning state schedules a
// second cycle.
if (this._lastPage === "login" && this._currentPage !== "login") {
this._flashMessage = "";
}
this._lastPage = this._currentPage;
}
protected createElement(tag: string) {
@@ -132,9 +167,27 @@ class HaConfigCloud extends HassRouterPage {
el.addEventListener("flash-message-changed", (ev) => {
this._flashMessage = (ev as ValueChangedEvent<string>).detail.value;
});
el.addEventListener("cloud-cancel-auto-login", () => {
this._autoLoginCancelled = true;
this._cancelAutoLogin();
});
el.addEventListener("cloud-auto-login-started", () => {
this._autoLoginCancelled = false;
});
return el;
}
private async _cancelAutoLogin() {
try {
await cancelCloudAutoLogin(this.hass);
} catch (_err) {
// Both callers are fire-and-forget navigations with nowhere to report
// this; the refresh below still reconciles whatever the backend kept.
} finally {
fireEvent(this, "ha-refresh-cloud-status");
}
}
protected updatePageEl(el) {
// We are not going to update if the current page if we are not logged in
// and the current page requires being logged in. Happens when we log out.
@@ -1,28 +1,14 @@
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list";
import "../../../../components/ha-list-item";
import "../../../../components/ha-svg-icon";
import { removeCloudData } from "../../../../data/cloud";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
import "./cloud-login";
import type { CloudLogin } from "./cloud-login";
@@ -30,212 +16,88 @@ import type { CloudLogin } from "./cloud-login";
export class CloudLoginPanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@property({ attribute: false }) public flashMessage?: string;
@query("cloud-login") private _cloudLoginElement!: CloudLogin;
@query("cloud-login") private _cloudLoginElement?: CloudLogin;
protected firstUpdated(): void {
this._focusEmail();
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
header="Home Assistant Cloud"
back-path="/config/cloud/start"
.header=${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
>
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="reset">
${this.hass.localize(
"ui.panel.config.cloud.account.reset_cloud_data"
)}
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="download">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction2"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
${
this.flashMessage
? html`<ha-alert
dismissable
@alert-dismissed-clicked=${this._dismissFlash}
>
Nabu&nbsp;Casa,&nbsp;Inc</a
>${this.hass.localize(
"ui.panel.config.cloud.login.introduction2a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction3"
)}
</p>
<p>
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.login.learn_more_link"
)}
</a>
</p>
</div>
${
this.flashMessage
? html`<ha-alert
dismissable
@alert-dismissed-clicked=${this._dismissFlash}
>
${this.flashMessage}
</ha-alert>`
: ""
}
<cloud-login
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
@cloud-forgot-password=${this._handleForgotPassword}
check-connection
></cloud-login>
<ha-card outlined>
<ha-list>
<ha-list-item @click=${this._handleRegister} twoline hasMeta>
${this.hass.localize(
"ui.panel.config.cloud.login.start_trial"
)}
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.cloud.login.trial_info"
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</ha-list>
</ha-card>
</ha-config-section>
${this.flashMessage}
</ha-alert>`
: nothing
}
<cloud-login
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
.lead=${this.hass.localize(
"ui.panel.config.cloud.login.sign_in_lead"
)}
check-connection
@cloud-forgot-password=${this._handleForgotPassword}
></cloud-login>
</div>
</hass-subpage>
`;
}
private async _focusEmail() {
const cloudLogin = this._cloudLoginElement;
if (!cloudLogin) {
return;
}
await cloudLogin.updateComplete;
cloudLogin.emailField?.focus();
}
private _handleForgotPassword() {
this._dismissFlash();
fireEvent(this, "cloud-email-changed", {
value: this._cloudLoginElement.emailField.value ?? "",
value: this._cloudLoginElement?.emailField?.value ?? this.email ?? "",
});
navigate("/config/cloud/forgot-password");
}
private _handleRegister() {
this._dismissFlash();
fireEvent(this, "cloud-email-changed", {
value: this._cloudLoginElement.emailField.value ?? "",
});
navigate("/config/cloud/register");
}
private _dismissFlash() {
fireEvent(this, "flash-message-changed", { value: "" });
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
const value = ev.detail.item.value;
switch (value) {
case "reset":
this._deleteCloudData();
break;
case "download":
this._downloadSupportPackage();
break;
}
}
private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_text"
),
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await removeCloudData(this.hass);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_failed"
),
text: err?.message,
});
return;
} finally {
fireEvent(this, "ha-refresh-cloud-status");
}
}
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() {
return [
haStyle,
cloudSubpageStyle,
cloudSignedOutStyle,
css`
.content {
padding-bottom: 24px;
gap: var(--ha-space-4);
}
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
h1 {
margin: 0;
ha-alert,
cloud-login {
display: block;
width: 100%;
max-width: 600px;
margin-inline: auto;
}
`,
];
+10 -32
View File
@@ -9,14 +9,12 @@ import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import { setAssistPipelinePreferred } from "../../../../data/assist_pipeline";
import { cloudLogin } from "../../../../data/cloud";
import { loginHaCloud } from "../../../../data/onboarding";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../lovelace/custom-card-helpers";
import { showCloudAlreadyConnectedDialog } from "../dialog-cloud-already-connected/show-dialog-cloud-already-connected";
@@ -37,6 +35,8 @@ export class CloudLogin extends LitElement {
@property({ type: Boolean, attribute: "card-less" }) public cardLess = false;
@property() public lead?: string;
@query("#email", true) public emailField!: HaInput;
@query("#password", true) private _passwordField!: HaInput;
@@ -50,21 +50,13 @@ export class CloudLogin extends LitElement {
return this._renderLoginForm();
}
return html`
<ha-card
outlined
.header=${this.localize(
`ui.panel.${this.translationKeyPanel}.login.sign_in`
)}
>
${this._renderLoginForm()}
</ha-card>
`;
return html`<ha-card outlined>${this._renderLoginForm()}</ha-card>`;
}
private _renderLoginForm() {
return html`
<div class="card-content login-form">
${this.lead ? html`<p class="lead">${this.lead}</p>` : nothing}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -219,28 +211,12 @@ export class CloudLogin extends LitElement {
try {
if (this.hass) {
const result = await cloudLogin({
await cloudLogin({
hass: this.hass,
email,
...(code ? { code } : { password }),
check_connection: checkConnection,
});
if (result.cloud_pipeline) {
if (
await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.cloud_pipeline_title"
),
text: this.hass.localize(
"ui.panel.config.cloud.login.cloud_pipeline_text"
),
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
})
) {
setAssistPipelinePreferred(this.hass, result.cloud_pipeline);
}
}
} else {
// for onboarding
await loginHaCloud({
@@ -317,9 +293,6 @@ export class CloudLogin extends LitElement {
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
.card-actions {
display: flex;
justify-content: space-between;
@@ -329,6 +302,11 @@ export class CloudLogin extends LitElement {
display: flex;
flex-direction: column;
}
.lead {
margin: 0 0 var(--ha-space-2);
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
`,
];
}
+540 -220
View File
@@ -1,212 +1,377 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { mdiEmailCheckOutline } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-spinner";
import "../../../../components/ha-svg-icon";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import { cloudRegister, cloudResendVerification } from "../../../../data/cloud";
import type { CloudAutoLogin, CloudStatus } from "../../../../data/cloud";
import {
attemptCloudAutoLoginNow,
cloudRegisterAutoLogin,
resendCloudAutoLoginConfirm,
subscribeCloudEvents,
} from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import { showToast } from "../../../../util/toast";
import "../cloud-signed-out-menu";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
const BACK_PATH = "/config/cloud/start";
@customElement("cloud-register")
export class CloudRegister extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@state() private _view: "form" | "confirm" = "form";
@state() private _requestInProgress = false;
@state() private _password = "";
@state() private _resendInProgress = false;
@state() private _autoLoginError?: string;
@state() private _error?: string;
@query("#email", true) private _emailField!: HaInput;
@state() private _success?: string;
@query("#password", true) private _passwordField!: HaInput;
@state() private _registeredEmail = "";
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
@query("#email") private _emailField?: HaInput;
@query("#password") private _passwordField?: HaInput;
@query("#confirm-title") private _confirmTitle?: HTMLElement;
private get _autoLogin(): CloudAutoLogin | null {
const status = this.cloudStatus;
if (!status || status.logged_in) {
return null;
}
return status.auto_login ?? null;
}
private get _pendingEmail(): string {
return this._registeredEmail || this._autoLogin?.email || "";
}
public connectedCallback(): void {
super.connectedCallback();
if (this._autoLogin) {
this._view = "confirm";
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._stopWatchingAutoLogin();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (this._view !== "confirm") {
return;
}
const failedKey = this._autoLogin?.failed;
if (failedKey && !this._autoLoginError) {
this._failAutoLogin(failedKey);
return;
}
this._watchAutoLogin();
}
protected render(): TemplateResult {
const confirming = this._view === "confirm";
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
back-path=${BACK_PATH}
.backCallback=${confirming ? this._handleConfirmBack : undefined}
.header=${this.hass.localize("ui.panel.config.cloud.register.headline")}
>
<cloud-signed-out-menu
slot="toolbar-icon"
.hass=${this.hass}
></cloud-signed-out-menu>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header"
>${this.hass.localize(
"ui.panel.config.cloud.register.headline"
)}</span
>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information2"
)}
</p>
<ul>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_remote_control"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_google_home"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_amazon_alexa"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_webhook_apps"
)}
</li>
</ul>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information3"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>
${this.hass.localize(
"ui.panel.config.cloud.register.information3a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information4"
)}
</p>
<ul>
<li>
<a
href="https://www.nabucasa.com/tos/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_terms_conditions"
)}
</a>
</li>
<li>
<a
href="https://www.nabucasa.com/privacy_policy/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_privacy_policy"
)}
</a>
</li>
</ul>
</div>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.register.create_account"
)}
><div class="card-content register-form">
${
this._error
? html`<ha-alert alert-type="error"
>${this._error}</ha-alert
>`
: ""
}
<ha-input
autofocus
id="email"
name="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
type="email"
autocomplete="email"
required
.value=${this.email ?? ""}
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-input>
<ha-input
id="password"
type="password"
password-toggle
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
)}
.value=${this._password}
autocomplete="new-password"
minlength="8"
required
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-input>
</div>
<div class="card-actions">
<button
class="link"
.disabled=${this._requestInProgress}
@click=${this._handleResendVerifyEmail}
>
${this.hass.localize(
"ui.panel.config.cloud.register.resend_confirm_email"
)}
</button>
<ha-progress-button
@click=${this._handleRegister}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.register.start_trial"
)}</ha-progress-button
>
</div>
</ha-card>
</ha-config-section>
${confirming ? this._renderConfirm() : this._renderForm()}
</div>
</hass-subpage>
`;
}
private _renderForm(): TemplateResult {
return html`
<ha-card outlined>
<div class="card-header-block">
<h2>
${this.hass.localize(
"ui.panel.config.cloud.register.create_account"
)}
</h2>
<p>
${this.hass.localize("ui.panel.config.cloud.register.information")}
</p>
</div>
<div class="card-content register-form">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<ha-input
autofocus
id="email"
name="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
type="email"
autocomplete="email"
required
.value=${this.email ?? ""}
.disabled=${this._requestInProgress}
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-input>
<ha-input
id="password"
type="password"
password-toggle
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
)}
autocomplete="new-password"
minlength="8"
required
.disabled=${this._requestInProgress}
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-input>
<p class="terms">
${this.hass.localize(
"ui.panel.config.cloud.register.terms_notice",
{
terms: html`<a
href="https://www.nabucasa.com/tos/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.cloud.register.link_terms_conditions"
)}</a
>`,
privacy_policy: html`<a
href="https://www.nabucasa.com/privacy_policy/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.cloud.register.link_privacy_policy"
)}</a
>`,
}
)}
</p>
</div>
<div class="card-actions split">
<ha-button
appearance="plain"
.disabled=${this._requestInProgress}
@click=${this._handleSignInInstead}
>
${this.hass.localize(
"ui.panel.config.cloud.register.sign_in_instead"
)}
</ha-button>
<ha-progress-button
@click=${this._handleRegister}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.register.start_trial"
)}</ha-progress-button
>
</div>
</ha-card>
`;
}
private _renderConfirm(): TemplateResult {
return html`
<ha-card outlined>
<div class="card-content confirm">
${
this._autoLoginError
? html`<ha-alert alert-type="error">
${this._autoLoginError}
</ha-alert>`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
this._success
? html`<ha-alert alert-type="success">${this._success}</ha-alert>`
: nothing
}
<div class="confirm-icon">
<ha-svg-icon .path=${mdiEmailCheckOutline}></ha-svg-icon>
</div>
<h2 id="confirm-title" tabindex="-1">
${this.hass.localize(
"ui.panel.config.cloud.register.check_your_email"
)}
</h2>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.confirm_email_body",
{
email: html`<span class="email">${this._pendingEmail}</span>`,
}
)}
</p>
${
this._autoLoginError
? nothing
: html`
<div class="waiting">
<ha-spinner size="tiny"></ha-spinner>
<span>
${this.hass.localize(
"ui.panel.config.cloud.register.waiting_for_confirmation"
)}
</span>
</div>
`
}
</div>
<div class="card-actions split confirm-actions">
<ha-button appearance="plain" @click=${this._handleCancelPending}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
${
this._autoLoginError
? html`<ha-button
appearance="accent"
@click=${this._handleSignInInstead}
>
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
</ha-button>`
: html`<ha-button
appearance="accent"
@click=${this._handleAttemptNow}
>
${this.hass.localize(
"ui.panel.config.cloud.register.clicked_confirm"
)}
</ha-button>`
}
</div>
</ha-card>
${
this._autoLoginError
? nothing
: html`
<p class="footnote">
${this.hass.localize(
"ui.panel.config.cloud.register.nothing_arrived",
{
resend_link: html`<button
class="link"
.disabled=${this._resendInProgress}
@click=${this._handleResendVerifyEmail}
>
${this.hass.localize(
"ui.panel.config.cloud.register.resend_link"
)}
</button>`,
}
)}
</p>
`
}
`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleRegister();
}
}
private _handleSignInInstead() {
const confirming = this._view === "confirm";
fireEvent(this, "cloud-email-changed", {
// The field wins on the form: it holds whatever was typed after a cancel.
value: this._emailField?.value || this._pendingEmail || this.email || "",
});
this._stopWatchingAutoLogin();
if (confirming) {
fireEvent(this, "cloud-cancel-auto-login");
}
// Replaces only from the waiting view, whose registration is being
// cancelled: browser-back must not return to it. The form is a page worth
// going back to.
navigate("/config/cloud/login", { replace: confirming });
}
private _handleConfirmBack = () => {
this._stopWatchingAutoLogin();
fireEvent(this, "cloud-cancel-auto-login");
navigate(BACK_PATH, { replace: true });
};
private _handleCancelPending() {
this._resetToForm();
fireEvent(this, "cloud-cancel-auto-login");
}
private _resetToForm() {
this._stopWatchingAutoLogin();
this._view = "form";
this._autoLoginError = undefined;
this._registeredEmail = "";
this._error = undefined;
this._success = undefined;
}
private async _handleRegister() {
const emailField = this._emailField;
const passwordField = this._passwordField;
if (!emailField || !passwordField) {
return;
}
if (!emailField.reportValidity()) {
passwordField.reportValidity();
emailField.focus();
@@ -222,92 +387,246 @@ export class CloudRegister extends LitElement {
const password = passwordField.value || "";
this._requestInProgress = true;
this._error = undefined;
try {
await cloudRegister(this.hass, email, password);
this._verificationEmailSent(email, "account_created");
await cloudRegisterAutoLogin(this.hass, email, password);
} catch (err: any) {
this._password = "";
this._requestInProgress = false;
passwordField.value = "";
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
private async _handleResendVerifyEmail() {
const emailField = this._emailField;
if (!emailField.reportValidity()) {
emailField.focus();
err?.body?.message || this.hass.localize("ui.common.unknown_error");
return;
}
const email = emailField.value || "";
this._requestInProgress = true;
const doResend = async (username: string) => {
try {
await cloudResendVerification(this.hass, username);
this._verificationEmailSent(username, "verification_email_sent");
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doResend(username.toLowerCase());
} else {
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
};
await doResend(email);
this._registeredEmail = email;
this._requestInProgress = false;
this._view = "confirm";
this._autoLoginError = undefined;
fireEvent(this, "cloud-email-changed", { value: email });
fireEvent(this, "cloud-auto-login-started");
fireEvent(this, "ha-refresh-cloud-status");
this._watchAutoLogin();
await this.updateComplete;
this._confirmTitle?.focus();
}
private _verificationEmailSent(
email: string,
messageKey: "account_created" | "verification_email_sent"
) {
this._requestInProgress = false;
this._password = "";
fireEvent(this, "cloud-email-changed", { value: email });
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
`ui.panel.config.cloud.register.${messageKey}`
),
private _watchAutoLogin() {
if (this._unsubCloudEvents || this._autoLoginError) {
return;
}
const subscription = subscribeCloudEvents(this.hass, (event) => {
if (event.type === "login") {
fireEvent(this, "ha-refresh-cloud-status");
} else if (event.type === "auto_login_failed") {
this._failAutoLogin(event.translation_key);
} else if (event.type === "auto_login_cancelled") {
this._resetToForm();
fireEvent(this, "ha-refresh-cloud-status");
}
});
this._unsubCloudEvents = subscription;
subscription.catch(() => {
if (this._unsubCloudEvents === subscription) {
this._unsubCloudEvents = undefined;
}
});
}
private async _localizeBackendMessage(
key: string,
domain = "cloud"
): Promise<string | undefined> {
const localize = await this.hass.loadBackendTranslation(
"exceptions",
domain
);
return (
localize(`component.${domain}.exceptions.${key}.message`) || undefined
);
}
private async _websocketErrorMessage(err: any): Promise<string> {
if (err?.translation_key) {
const message = await this._localizeBackendMessage(
err.translation_key,
err.translation_domain
);
if (message) {
return message;
}
}
return err?.message || this.hass.localize("ui.common.unknown_error");
}
private async _failAutoLogin(translationKey?: string | null) {
this._stopWatchingAutoLogin();
const reason = translationKey
? await this._localizeBackendMessage(translationKey)
: undefined;
this._error = undefined;
this._success = undefined;
this._autoLoginError =
reason ||
this.hass.localize("ui.panel.config.cloud.register.auto_login_failed");
fireEvent(this, "ha-refresh-cloud-status");
}
private _stopWatchingAutoLogin() {
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
this._unsubCloudEvents = undefined;
}
private async _handleAttemptNow() {
this._watchAutoLogin();
try {
await attemptCloudAutoLoginNow(this.hass);
} catch (err: any) {
showToast(this, { message: await this._websocketErrorMessage(err) });
return;
}
showToast(this, {
message: {
translationKey: "ui.panel.config.cloud.register.checking_confirmation",
},
});
}
private async _handleResendVerifyEmail() {
if (this._resendInProgress) {
return;
}
this._error = undefined;
this._success = undefined;
this._resendInProgress = true;
try {
await resendCloudAutoLoginConfirm(this.hass);
this._success = this.hass.localize(
"ui.panel.config.cloud.register.verification_email_sent"
);
} catch (err: any) {
this._error = await this._websocketErrorMessage(err);
} finally {
this._resendInProgress = false;
}
}
static get styles() {
return [
haStyle,
cloudSubpageStyle,
cloudSignedOutStyle,
css`
[slot="introduction"] {
margin: -1em 0;
.content {
gap: var(--ha-space-3);
}
[slot="introduction"] a {
color: var(--primary-color);
.card-header-block {
padding: var(--ha-space-4) var(--ha-space-4) 0;
}
a {
color: var(--primary-color);
}
h1 {
margin: 0;
.card-header-block p {
margin: var(--ha-space-2) 0 0;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.register-form {
display: flex;
flex-direction: column;
}
.card-actions {
.terms {
margin: var(--ha-space-2) 0 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.terms a,
.footnote a,
.footnote button.link {
color: var(--primary-color);
}
.card-actions.split {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-actions.confirm-actions {
border-top: none;
}
.confirm {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--ha-space-3);
padding: var(--ha-space-8) var(--ha-space-4) var(--ha-space-5);
}
.confirm ha-alert {
width: 100%;
text-align: initial;
}
.confirm-icon {
width: 56px;
height: 56px;
border-radius: var(--ha-border-radius-pill);
background: color-mix(in srgb, var(--info-color) 18%, transparent);
color: var(--info-color);
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 28px;
}
/* Focused programmatically to announce the view; no visible ring. */
#confirm-title:focus {
outline: none;
}
.confirm p {
margin: 0;
max-width: 420px;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.confirm .email {
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
}
.waiting {
display: flex;
align-items: center;
gap: var(--ha-space-2);
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.footnote {
display: block;
width: 100%;
max-width: 600px;
margin: 0 auto;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
text-wrap: pretty;
}
/* Not column-reverse: that paints the buttons against DOM order, so
keyboard and screen-reader users reach them back to front. */
@media (max-width: 500px) {
.card-actions.split {
flex-direction: column;
align-items: stretch;
gap: var(--ha-space-2);
}
}
`,
];
}
@@ -319,6 +638,7 @@ declare global {
}
interface HASSDomEvents {
"cloud-done": { flashMessage: string };
"cloud-cancel-auto-login": undefined;
"cloud-auto-login-started": undefined;
}
}
@@ -0,0 +1,300 @@
import {
mdiBackupRestore,
mdiCellphone,
mdiEarth,
mdiHandHeart,
mdiMicrophone,
mdiMicrophoneMessage,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { navigate } from "../../../../common/navigate";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../cloud-signed-out-menu";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
const FEATURES = [
{ icon: mdiHandHeart, color: "--red-color", key: "feature_support" },
{ icon: mdiEarth, color: "--blue-color", key: "feature_remote" },
{ icon: mdiBackupRestore, color: "--green-color", key: "feature_backup" },
{
icon: mdiMicrophoneMessage,
color: "--cyan-color",
key: "feature_voice_control",
},
{
icon: mdiMicrophone,
color: "--purple-color",
key: "feature_voice_quality",
},
{ icon: mdiCellphone, color: "--primary-color", key: "feature_companion" },
] as const;
@customElement("cloud-start")
export class CloudStart extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize(
"ui.panel.config.cloud.login.landing_title"
)}
>
<cloud-signed-out-menu
slot="toolbar-icon"
.hass=${this.hass}
></cloud-signed-out-menu>
<div class="content">
<div class="landing">
<div class="pitch">
<h2>
${this.hass.localize("ui.panel.config.cloud.login.hero_title")}
</h2>
<p class="lead">
${this.hass.localize("ui.panel.config.cloud.login.hero_lead")}
</p>
</div>
<ha-card outlined class="features">
<div class="card-content feature-list">
${FEATURES.map(
(feature) => html`
<div class="feature">
<div
class="icon-tile"
style=${styleMap({
"--feature-tint": `var(${feature.color})`,
})}
>
<ha-svg-icon .path=${feature.icon}></ha-svg-icon>
</div>
<div class="feature-text">
<div class="feature-title">
${this.hass.localize(
`ui.panel.config.cloud.login.${feature.key}_title`
)}
</div>
<p>
${this.hass.localize(
`ui.panel.config.cloud.login.${feature.key}_body`
)}
</p>
</div>
</div>
`
)}
</div>
</ha-card>
<div class="actions">
<div class="action-buttons">
<ha-button
size="l"
appearance="accent"
@click=${this._handleRegister}
>
${this.hass.localize(
"ui.panel.config.cloud.register.headline"
)}
</ha-button>
<ha-button appearance="plain" @click=${this._handleSignIn}>
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
</ha-button>
</div>
<p class="trial-note">
${this.hass.localize("ui.panel.config.cloud.login.trial_note")}
</p>
</div>
</div>
<p class="footnote">
${this.hass.localize("ui.panel.config.cloud.login.partner_note", {
nabu_casa_link: html`<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>`,
})}
</p>
</div>
</hass-subpage>
`;
}
private _handleSignIn() {
navigate("/config/cloud/login");
}
private _handleRegister() {
navigate("/config/cloud/register");
}
static get styles() {
return [
haStyle,
cloudSubpageStyle,
cloudSignedOutStyle,
css`
.content {
min-height: 100%;
gap: var(--ha-space-4);
container-type: inline-size;
}
.landing {
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
width: 100%;
max-width: 600px;
margin-inline: auto;
}
.pitch .lead {
margin: var(--ha-space-2) 0 0;
line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color);
text-wrap: pretty;
}
.feature-list {
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
padding: var(--ha-space-5) var(--ha-space-4);
}
.feature {
display: flex;
gap: var(--ha-space-3);
align-items: flex-start;
}
.feature-title {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.feature-text p {
margin: var(--ha-space-1) 0 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
text-wrap: pretty;
}
.icon-tile {
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-pill);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: color-mix(in srgb, var(--feature-tint) 15%, transparent);
color: var(--feature-tint);
}
/* Keeps the primary action reachable while the feature list scrolls. */
.actions {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
padding: var(--ha-space-3) 0
calc(var(--safe-area-inset-bottom) + var(--ha-space-3));
background: var(--primary-background-color);
box-shadow: 0 -1px 0 var(--divider-color);
}
.action-buttons {
display: flex;
gap: var(--ha-space-2);
}
.action-buttons ha-button {
flex: 1;
/* Matches the primary action's size="l" height without also giving
the secondary its larger type. */
--ha-button-height: 48px;
}
.trial-note {
margin: 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
}
.footnote {
width: 100%;
max-width: 960px;
/* Keep the footer at the bottom on short pages. */
margin: auto auto 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
text-wrap: pretty;
}
.footnote a {
color: var(--primary-color);
}
@container (min-width: 700px) {
.landing {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
grid-template-rows: auto auto 1fr;
column-gap: var(--ha-space-7);
row-gap: var(--ha-space-6);
align-items: start;
max-width: 960px;
}
.pitch {
grid-column: 1;
grid-row: 1;
}
.actions {
grid-column: 1;
grid-row: 2;
position: static;
padding: 0;
background: none;
box-shadow: none;
}
.features {
grid-column: 2;
grid-row: 1 / 4;
}
.action-buttons {
flex-direction: column;
max-width: 340px;
}
.action-buttons ha-button {
flex: initial;
width: 100%;
}
.trial-note {
text-align: start;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-start": CloudStart;
}
}
+41 -3
View File
@@ -1,9 +1,10 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { CloudStatus } from "../../data/cloud";
import { fetchCloudStatus } from "../../data/cloud";
import { fetchCloudStatus, subscribeCloudEvents } from "../../data/cloud";
import {
entityRegistryByEntityId,
entityRegistryById,
@@ -256,6 +257,10 @@ class HaPanelConfig extends HassRouterPage {
private _listeners: (() => void)[] = [];
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
private _cloudStatusRequestId = 0;
public connectedCallback() {
super.connectedCallback();
this._listeners.push(
@@ -268,6 +273,10 @@ class HaPanelConfig extends HassRouterPage {
this._wideSidebar = matches;
})
);
if (this.hass && isComponentLoaded(this.hass.config, "cloud")) {
this._subscribeCloudEvents();
this._updateCloudStatus();
}
}
public disconnectedCallback() {
@@ -275,19 +284,42 @@ class HaPanelConfig extends HassRouterPage {
while (this._listeners.length) {
this._listeners.pop()!();
}
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
this._unsubCloudEvents = undefined;
entityRegistryByEntityId.clear();
entityRegistryById.clear();
}
private _subscribeCloudEvents() {
if (
this._unsubCloudEvents ||
!this.hass ||
!isComponentLoaded(this.hass.config, "cloud")
) {
return;
}
const subscription = subscribeCloudEvents(this.hass, () => {
this._updateCloudStatus();
});
this._unsubCloudEvents = subscription;
subscription.catch(() => {
if (this._unsubCloudEvents === subscription) {
this._unsubCloudEvents = undefined;
}
});
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
this.hass.loadBackendTranslation("services");
if (isComponentLoaded(this.hass.config, "cloud")) {
this._updateCloudStatus();
this.addEventListener("connection-status", (ev) => {
if (ev.detail === "connected") {
this._updateCloudStatus();
this._subscribeCloudEvents();
}
});
}
@@ -321,7 +353,13 @@ class HaPanelConfig extends HassRouterPage {
}
private async _updateCloudStatus() {
this._cloudStatus = await fetchCloudStatus(this.hass);
const requestId = ++this._cloudStatusRequestId;
const status = await fetchCloudStatus(this.hass);
if (requestId !== this._cloudStatusRequestId) {
return;
}
this._cloudStatus = status;
if (
// Relayer connecting
@@ -101,12 +101,16 @@ export const renderConfigEntryError = (
): TemplateResult => {
if (entry.reason) {
if (entry.error_reason_translation_key) {
// The error may be translated by another integration, e.g. one raised by
// a shared helper and owned by the homeassistant integration.
const translationDomain =
entry.error_reason_translation_domain || entry.domain;
const lokalisePromExc = hass
.loadBackendTranslation("exceptions", entry.domain)
.loadBackendTranslation("exceptions", translationDomain)
.then(
(localize) =>
localize(
`component.${entry.domain}.exceptions.${entry.error_reason_translation_key}.message`,
`component.${translationDomain}.exceptions.${entry.error_reason_translation_key}.message`,
entry.error_reason_translation_placeholders ?? undefined
) || entry.reason
);
@@ -267,6 +267,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
})
@@ -256,10 +256,14 @@ class MqttSubscribeCard extends LitElement {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--ha-space-2);
}
ha-button {
align-self: flex-start;
margin-top: var(--ha-space-3);
}
ha-select {
width: 96px;
margin: 0 8px;
}
ha-input {
flex: 1;
@@ -281,10 +285,6 @@ class MqttSubscribeCard extends LitElement {
@media screen and (max-width: 600px) {
ha-select {
display: block;
margin-left: 0px;
margin-top: 8px;
margin-inline-start: 0px;
margin-inline-end: initial;
}
ha-input {
flex: auto;
@@ -1,11 +1,13 @@
import { mdiContentCopy } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-adaptive-dialog";
import "../../../../../components/ha-icon-button";
import "../../../../../components/item/ha-list-item-value";
import "../../../../../components/list/ha-grouped-list";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
@@ -85,64 +87,34 @@ class DialogSerialPortInfo extends LitElement {
}
return html`
<ha-dialog
<ha-adaptive-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.serial.port_information"
)}
@closed=${this._dialogClosed}
>
<table>
<tbody>
${this._fields().map(
([label, value]) => html`
<tr>
<th>${label}</th>
<td>${value}</td>
</tr>
`
)}
</tbody>
</table>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._copyToClipboard}
>
${this.hass.localize("ui.common.copy")}
</ha-button>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize("ui.common.copy")}
.path=${mdiContentCopy}
@click=${this._copyToClipboard}
></ha-icon-button>
<ha-grouped-list>
${this._fields().map(
([label, value]) =>
html`<ha-list-item-value .label=${label}
>${value}</ha-list-item-value
>`
)}
</ha-grouped-list>
</ha-adaptive-dialog>
`;
}
static readonly styles: CSSResultGroup = css`
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: start;
vertical-align: top;
white-space: nowrap;
padding-inline-end: var(--ha-space-4);
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
td {
width: 100%;
word-break: break-all;
}
tr:not(:first-child) th,
tr:not(:first-child) td {
padding-top: var(--ha-space-2);
ha-grouped-list {
--ha-list-item-value-max-width: 80%;
}
`;
}
@@ -192,6 +192,16 @@ export class SerialConfigDashboard extends LitElement {
});
}
private _renderConsumerIcon(src: string, alt: string): TemplateResult {
return html`<img
slot="start"
.src=${src}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${alt}
/>`;
}
private _renderConsumer(consumer: SerialPortConsumer): TemplateResult {
const href =
consumer.kind === "config_entry"
@@ -202,21 +212,26 @@ export class SerialConfigDashboard extends LitElement {
<ha-md-list-item type="link" href=${href} class="consumer">
${
consumer.kind === "config_entry"
? html`<img
slot="start"
.src=${brandsUrl(
? this._renderConsumerIcon(
brandsUrl(
{
domain: consumer.domain!,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${consumer.domain!}
/>`
: html`<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>`
),
consumer.domain!
)
: consumer.kind === "app"
? this._renderConsumerIcon(
`/api/hassio/addons/${consumer.slug}/icon`,
consumer.slug!
)
: html`<ha-svg-icon
slot="start"
.path=${mdiPuzzle}
></ha-svg-icon>`
}
<div slot="headline">${this._consumerName(consumer)}</div>
<ha-icon-next slot="end"></ha-icon-next>
@@ -40,6 +40,7 @@ class HaConfigScene extends HassRouterPage {
},
edit: {
tag: "ha-scene-editor",
itemId: true,
},
},
};
+16 -5
View File
@@ -911,11 +911,15 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _subscribeEvents() {
this._unsubscribeEvents =
await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
if (!this.isConnected || this._mode !== "live") {
unsubscribe();
return;
}
this._unsubscribeEvents = unsubscribe;
}
private _showMoreInfo(ev: Event) {
@@ -928,6 +932,9 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
try {
config = await getSceneConfig(this.hass, this.sceneId!);
} catch (err: any) {
if (!this.isConnected) {
return;
}
await showAlertDialog(this, {
text:
err.status_code === 404
@@ -943,6 +950,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return;
}
if (!this.isConnected) {
return;
}
if (!config.entities) {
config.entities = {};
}
@@ -40,9 +40,11 @@ class HaConfigScript extends HassRouterPage {
},
edit: {
tag: "ha-script-editor",
itemId: true,
},
show: {
tag: "ha-script-editor",
itemId: true,
},
trace: {
tag: "ha-script-trace",
@@ -1,193 +0,0 @@
import { consume } from "@lit/context";
import {
mdiFlagCheckered,
mdiPause,
mdiPlay,
mdiRestart,
mdiStop,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
consumeEntityState,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import type { HaControlButton } from "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-svg-icon";
import { apiContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistant, HomeAssistantApi } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import {
DEFAULT_TIMER_ACTIONS,
TIMER_ACTIONS,
type TimerActionsCardFeatureConfig,
type LovelaceCardFeatureContext,
} from "./types";
const supportsTimerActionsCardFeatureFromState = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return domain === "timer";
};
export const supportsTimerActionsCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
return supportsTimerActionsCardFeatureFromState(stateObj);
};
interface TimerButton {
translationKey: string;
icon: string;
serviceName: string;
disabled: boolean;
}
export const TIMER_ACTIONS_BUTTON: Record<
string,
(stateObj: HassEntity) => TimerButton
> = {
// timer.start starts an idle timer, resumes a paused one, and restarts an
// active one with its configured duration.
start: (stateObj) =>
stateObj.state === "active"
? {
translationKey: "restart",
icon: mdiRestart,
serviceName: "start",
disabled: false,
}
: {
translationKey: "start",
icon: mdiPlay,
serviceName: "start",
disabled: false,
},
pause: (stateObj) => ({
translationKey: "pause",
icon: mdiPause,
serviceName: "pause",
disabled: stateObj.state !== "active",
}),
cancel: (stateObj) => ({
translationKey: "cancel",
icon: mdiStop,
serviceName: "cancel",
disabled: stateObj.state === "idle",
}),
finish: (stateObj) => ({
translationKey: "finish",
icon: mdiFlagCheckered,
serviceName: "finish",
disabled: stateObj.state === "idle",
}),
};
@customElement("hui-timer-actions-card-feature")
class HuiTimerActionsCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
private _stateObj?: HassEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state() private _config?: TimerActionsCardFeatureConfig;
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-timer-actions-card-feature-editor");
return document.createElement("hui-timer-actions-card-feature-editor");
}
static getStubConfig(): TimerActionsCardFeatureConfig {
return {
type: "timer-actions",
};
}
public setConfig(config: TimerActionsCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.context ||
!this._stateObj ||
!supportsTimerActionsCardFeatureFromState(this._stateObj)
) {
return null;
}
const actions = this._config?.actions ?? DEFAULT_TIMER_ACTIONS;
return html`
<ha-control-button-group>
${actions
.filter((action) => TIMER_ACTIONS.includes(action))
.map((action) => {
const button = TIMER_ACTIONS_BUTTON[action](this._stateObj!);
return html`
<ha-control-button
.entry=${button}
.label=${this._localize(
// @ts-ignore
`ui.card.timer.actions.${button.translationKey}`
)}
@click=${this._onActionTap}
.disabled=${
button.disabled || this._stateObj?.state === UNAVAILABLE
}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
private _onActionTap(
ev: MouseEvent &
HASSDomCurrentTargetEvent<HaControlButton & { entry: TimerButton }>
): void {
ev.stopPropagation();
this._api.callService("timer", ev.currentTarget.entry.serviceName, {
entity_id: this._stateObj!.entity_id,
});
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-timer-actions-card-feature": HuiTimerActionsCardFeature;
}
}
@@ -1,224 +0,0 @@
import { consume } from "@lit/context";
import { mdiTimerOutline } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { createDurationData } from "../../../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../../../common/datetime/duration_to_seconds";
import { formatNumericDuration } from "../../../common/datetime/format_duration";
import {
consumeEntityState,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import type { HaControlButton } from "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-control-select-menu";
import "../../../components/ha-svg-icon";
import { apiContext, internationalizationContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import { normalizeTimerDuration } from "../../../data/timer";
import type { FrontendLocaleData } from "../../../data/translation";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantInternationalization,
} from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
LovelaceCardFeatureContext,
TimerPresetsCardFeatureConfig,
} from "./types";
const supportsTimerPresetsCardFeatureFromState = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return domain === "timer";
};
export const supportsTimerPresetsCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
return supportsTimerPresetsCardFeatureFromState(stateObj);
};
interface TimerPreset {
duration: number;
label: string;
}
// Presets with an unparseable or zero duration are dropped. Durations are
// normalized to seconds so what is shown and what is sent to timer.start
// always match, and numeric presets ("90") render the same as their string
// form ("0:01:30").
export const computeTimerPresets = (
presets: (string | number)[],
locale: FrontendLocaleData
): TimerPreset[] =>
presets.reduce<TimerPreset[]>((result, preset) => {
const durationData = createDurationData(preset);
const seconds = durationData ? durationDataToSeconds(durationData) : 0;
if (durationData && seconds > 0) {
const label = formatNumericDuration(
locale,
normalizeTimerDuration(durationData)
);
if (label) {
result.push({ duration: seconds, label });
}
}
return result;
}, []);
@customElement("hui-timer-presets-card-feature")
class HuiTimerPresetsCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
private _stateObj?: HassEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@state() private _config?: TimerPresetsCardFeatureConfig;
private _presets = memoizeOne(
(presets: (string | number)[] | undefined, locale: FrontendLocaleData) =>
computeTimerPresets(presets ?? [], locale)
);
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-timer-presets-card-feature-editor");
return document.createElement("hui-timer-presets-card-feature-editor");
}
static getStubConfig(): TimerPresetsCardFeatureConfig {
return {
type: "timer-presets",
presets: ["0:01:00", "0:05:00", "0:10:00"],
};
}
public setConfig(config: TimerPresetsCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render() {
if (
!this._config ||
!this.context ||
!this._stateObj ||
!this._locale ||
!supportsTimerPresetsCardFeatureFromState(this._stateObj)
) {
return nothing;
}
const presets = this._presets(this._config.presets, this._locale);
if (!presets.length) {
return nothing;
}
if (this._config.style === "dropdown") {
return html`
<ha-control-select-menu
show-arrow
.label=${this._localize("ui.card.timer.presets")}
.options=${presets.map((preset) => ({
value: String(preset.duration),
label: preset.label,
}))}
.disabled=${this._stateObj.state === UNAVAILABLE}
@wa-select=${this._onPresetSelect}
>
<ha-svg-icon slot="icon" .path=${mdiTimerOutline}></ha-svg-icon>
</ha-control-select-menu>
`;
}
return html`
<ha-control-button-group>
${presets.map(
(preset) => html`
<ha-control-button
.preset=${preset}
.label=${this._localize("ui.card.timer.start_preset", {
duration: preset.label,
})}
.disabled=${this._stateObj!.state === UNAVAILABLE}
@click=${this._onPresetTap}
>
${preset.label}
</ha-control-button>
`
)}
</ha-control-button-group>
`;
}
private _onPresetTap(
ev: MouseEvent &
HASSDomCurrentTargetEvent<HaControlButton & { preset: TimerPreset }>
): void {
ev.stopPropagation();
this._startPreset(ev.currentTarget.preset);
}
private _onPresetSelect(ev: CustomEvent<{ item?: { value: string } }>): void {
ev.stopPropagation();
const value = ev.detail.item?.value;
if (value === undefined || !this._config || !this._locale) {
return;
}
const presets = this._presets(this._config.presets, this._locale);
const preset = presets.find((p) => String(p.duration) === value);
if (preset) {
this._startPreset(preset);
}
}
private _startPreset(preset: TimerPreset): void {
this._api.callService("timer", "start", {
entity_id: this._stateObj!.entity_id,
duration: preset.duration,
});
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-timer-presets-card-feature": HuiTimerPresetsCardFeature;
}
}
@@ -40,8 +40,6 @@ import { supportsSelectOptionsCardFeature } from "./hui-select-options-card-feat
import { supportsTemperatureForecastCardFeature } from "./hui-temperature-forecast-card-feature";
import { supportsTargetHumidityCardFeature } from "./hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "./hui-target-temperature-card-feature";
import { supportsTimerActionsCardFeature } from "./hui-timer-actions-card-feature";
import { supportsTimerPresetsCardFeature } from "./hui-timer-presets-card-feature";
import { supportsToggleCardFeature } from "./hui-toggle-card-feature";
import { supportsTrendGraphCardFeature } from "./hui-trend-graph-card-feature";
import { supportsUpdateActionsCardFeature } from "./hui-update-actions-card-feature";
@@ -106,8 +104,6 @@ export const UI_FEATURE_TYPES = [
"trend-graph",
"target-humidity",
"target-temperature",
"timer-actions",
"timer-presets",
"toggle",
"update-actions",
"vacuum-commands",
@@ -164,8 +160,6 @@ export const SUPPORTS_FEATURE_TYPES: Record<UiFeatureType, SupportsFeature> = {
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
"temperature-forecast": supportsTemperatureForecastCardFeature,
"timer-actions": supportsTimerActionsCardFeature,
"timer-presets": supportsTimerPresetsCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,
@@ -195,27 +195,6 @@ export interface ToggleCardFeatureConfig {
type: "toggle";
}
export const TIMER_ACTIONS = ["start", "pause", "cancel", "finish"] as const;
export type TimerActions = (typeof TIMER_ACTIONS)[number];
export const DEFAULT_TIMER_ACTIONS: TimerActions[] = [
"start",
"pause",
"cancel",
];
export interface TimerActionsCardFeatureConfig {
type: "timer-actions";
actions?: TimerActions[];
}
export interface TimerPresetsCardFeatureConfig {
type: "timer-presets";
style?: "buttons" | "dropdown";
presets?: (string | number)[];
}
export interface WaterHeaterOperationModesCardFeatureConfig {
type: "water-heater-operation-modes";
style?: "dropdown" | "icons";
@@ -380,8 +359,6 @@ export type LovelaceCardFeatureConfig =
| TrendGraphCardFeatureConfig
| TargetHumidityCardFeatureConfig
| TargetTemperatureCardFeatureConfig
| TimerActionsCardFeatureConfig
| TimerPresetsCardFeatureConfig
| ToggleCardFeatureConfig
| UpdateActionsCardFeatureConfig
| VacuumCommandsCardFeatureConfig
@@ -83,7 +83,6 @@ const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
],
alarm_control_panel: [TILE_VARIANT, ["alarm-modes"]],
counter: [TILE_VARIANT, ["counter-actions"]],
timer: [TILE_VARIANT, ["timer-actions"]],
input_select: SELECT_VARIANTS,
select: SELECT_VARIANTS,
input_number: NUMERIC_INPUT_VARIANTS,
@@ -0,0 +1,39 @@
/**
* When battery is charging from grid, per-source import attribution is
* ambiguous if multiple grid sources have data in the same period.
* Rewrites single-source periods in place on `fromGridBySource`, and returns
* a combined used-grid map for multi-source periods. Returns undefined when
* no combined series is needed so the chart does not add an empty legend item.
*/
export function buildCombinedUsedGrid(
fromGridBySource: Record<string, Record<number, number>>,
gridToBattery: Record<number, number>,
usedGrid: Record<number, number>
): Record<number, number> | undefined {
const used_grid: Record<number, number> = {};
for (const [start, grid_to_battery] of Object.entries(gridToBattery)) {
if (!grid_to_battery) {
continue;
}
let noOfSources = 0;
let source: string | undefined;
for (const [key, stats] of Object.entries(fromGridBySource)) {
if (stats[start]) {
source = key;
noOfSources++;
}
if (noOfSources > 1) {
break;
}
}
if (noOfSources === 1 && source) {
fromGridBySource[source][start] = usedGrid[start];
} else {
Object.values(fromGridBySource).forEach((stats) => {
delete stats[start];
});
used_grid[start] = usedGrid[start];
}
}
return Object.keys(used_grid).length > 0 ? used_grid : undefined;
}
@@ -44,6 +44,7 @@ import {
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
import { buildCombinedUsedGrid } from "./energy-usage-graph-used-grid";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@@ -585,7 +586,8 @@ export class HuiEnergyUsageGraphCard
// Only add solar/battery consumption series when such a source is
// actually configured, otherwise the legend shows empty solar/battery
// entries for grid-only setups.
// entries for grid-only setups. Combined used_grid is a fallback for
// multi-source battery charging; skip it when it has no points.
if (statIdsByCat.solar) {
combinedData.used_solar = { used_solar: consumptionData.used_solar };
}
@@ -596,38 +598,14 @@ export class HuiEnergyUsageGraphCard
}
if (combinedData.from_grid && summedData.to_battery) {
const used_grid = {};
// If we have to_battery and multiple grid sources in the same period, we
// can't determine which source was used. So delete all the individual
// sources and replace with a 'combined from grid' value.
for (const [start, grid_to_battery] of Object.entries(
consumptionData.grid_to_battery
)) {
if (!grid_to_battery) {
continue;
}
let noOfSources = 0;
let source: string;
for (const [key, stats] of Object.entries(combinedData.from_grid)) {
if (stats[start]) {
source = key;
noOfSources++;
}
if (noOfSources > 1) {
break;
}
}
if (noOfSources === 1) {
combinedData.from_grid[source!][start] =
consumptionData.used_grid[start];
} else {
Object.values(combinedData.from_grid).forEach((stats) => {
delete stats[start];
});
used_grid[start] = consumptionData.used_grid[start];
}
const used_grid = buildCombinedUsedGrid(
combinedData.from_grid,
consumptionData.grid_to_battery,
consumptionData.used_grid
);
if (used_grid) {
combinedData.used_grid = { used_grid };
}
combinedData.used_grid = { used_grid };
}
const uniqueKeys = summedData.timestamps;
+1 -48
View File
@@ -1,5 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -19,7 +18,6 @@ import "../../../components/tile/ha-tile-container";
import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import { timerJustFinished } from "../../../data/timer";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
@@ -93,31 +91,6 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
@state() private _featureContext: LovelaceCardFeatureContext = {};
@state() private _timerFinished = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
!changedProps.has("hass") ||
!this._config?.entity ||
computeDomain(this._config.entity) !== "timer"
) {
return;
}
const stateObj = this.hass?.states[this._config.entity];
const oldStateObj = (changedProps.get("hass") as HomeAssistant | undefined)
?.states[this._config.entity];
if (stateObj && timerJustFinished(oldStateObj, stateObj)) {
this._timerFinished = true;
}
}
private _timerFinishedAnimationEnded(ev: AnimationEvent): void {
if (ev.animationName === "timer-finished-pulse") {
this._timerFinished = false;
}
}
public setConfig(config: TileCardConfig): void {
if (!config.entity) {
throw new Error("Specify an entity");
@@ -339,11 +312,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.imageUrl=${imageUrl}
data-domain=${ifDefined(domain)}
data-state=${ifDefined(stateObj?.state)}
class=${classMap({
image: hasImage,
"timer-finished": this._timerFinished,
})}
@animationend=${this._timerFinishedAnimationEnded}
class=${classMap({ image: hasImage })}
>
${
hasImage
@@ -425,22 +394,6 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
animation: pulse 1s infinite;
}
ha-tile-icon.timer-finished {
animation: timer-finished-pulse 0.5s ease-in-out 2;
}
@keyframes timer-finished-pulse {
50% {
--tile-icon-color: var(--error-color);
}
}
@media (prefers-reduced-motion: reduce) {
ha-tile-icon.timer-finished {
animation-duration: var(--ha-animation-duration-none);
}
}
/* Make sure we display the whole image */
ha-tile-icon.image[data-domain="update"] {
--tile-icon-border-radius: var(--ha-border-radius-square);
@@ -35,8 +35,6 @@ import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-card-feature";
import "../card-features/hui-target-humidity-card-feature";
import "../card-features/hui-target-temperature-card-feature";
import "../card-features/hui-timer-actions-card-feature";
import "../card-features/hui-timer-presets-card-feature";
import "../card-features/hui-toggle-card-feature";
import "../card-features/hui-update-actions-card-feature";
import "../card-features/hui-vacuum-commands-card-feature";
@@ -100,8 +98,6 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"target-humidity",
"target-temperature",
"temperature-forecast",
"timer-actions",
"timer-presets",
"toggle",
"update-actions",
"vacuum-commands",
@@ -1,8 +1,15 @@
import type { LovelacePanelConfig } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import { fetchConfig, saveConfig } from "../../../data/lovelace/config/types";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../data/lovelace/config/types";
import {
fetchConfig,
isStrategyDashboard,
saveConfig,
} from "../../../data/lovelace/config/types";
import { fetchDashboards } from "../../../data/lovelace/dashboard";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
@@ -37,7 +44,15 @@ export const addEntitiesToLovelaceView = async (
return;
}
let lovelaceConfig;
// A strategy dashboard has no views of its own until the user takes control.
const hasViewList = (
config: LovelaceRawConfig | undefined
): config is LovelaceConfig => !!config && !isStrategyDashboard(config);
const hasViews = (config: LovelaceRawConfig | undefined) =>
hasViewList(config) && !!config.views?.length;
let lovelaceConfig: LovelaceRawConfig | undefined;
let urlPath: string | null = null;
if (mainLovelaceMode === "storage") {
try {
@@ -47,25 +62,36 @@ export const addEntitiesToLovelaceView = async (
}
}
if (!lovelaceConfig && storageDashs.length) {
// find first dashoard not in generated mode
if (!hasViews(lovelaceConfig)) {
// Prefer a dashboard that has views to add the card to, but keep the first
// usable one as a fallback so the user still gets the dashboard picker.
for (const storageDash of storageDashs) {
try {
// eslint-disable-next-line no-await-in-loop
lovelaceConfig = await fetchConfig(
const dashConfig = await fetchConfig(
hass.connection,
storageDash.url_path,
false
);
urlPath = storageDash.url_path;
break;
if (!hasViewList(dashConfig)) {
continue;
}
if (!hasViewList(lovelaceConfig)) {
lovelaceConfig = dashConfig;
urlPath = storageDash.url_path;
}
if (hasViews(dashConfig)) {
lovelaceConfig = dashConfig;
urlPath = storageDash.url_path;
break;
}
} catch (_err: any) {
// dashboard is in generated mode
}
}
}
if (!lovelaceConfig) {
if (!hasViewList(lovelaceConfig)) {
if (dashboards.length > storageDashs.length) {
// all storage dashboards are generated, but we have YAML dashboards just show the YAML config
showSuggestCardDialog(element, {
@@ -94,7 +120,7 @@ export const addEntitiesToLovelaceView = async (
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
lovelaceConfig: lovelaceConfig!,
lovelaceConfig,
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
try {
await saveConfig(hass!, null, newConfig);
@@ -64,8 +64,6 @@ import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature";
import { supportsTimerActionsCardFeature } from "../../card-features/hui-timer-actions-card-feature";
import { supportsTimerPresetsCardFeature } from "../../card-features/hui-timer-presets-card-feature";
import { supportsToggleCardFeature } from "../../card-features/hui-toggle-card-feature";
import { supportsTrendGraphCardFeature } from "../../card-features/hui-trend-graph-card-feature";
import { supportsUpdateActionsCardFeature } from "../../card-features/hui-update-actions-card-feature";
@@ -132,8 +130,6 @@ const UI_FEATURE_TYPES = [
"target-humidity",
"target-temperature",
"temperature-forecast",
"timer-actions",
"timer-presets",
"toggle",
"update-actions",
"vacuum-commands",
@@ -173,8 +169,6 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"media-player-volume-slider",
"numeric-input",
"select-options",
"timer-actions",
"timer-presets",
"trend-graph",
"update-actions",
"vacuum-commands",
@@ -230,8 +224,6 @@ const SUPPORTS_FEATURE_TYPES: Record<
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
"temperature-forecast": supportsTemperatureForecastCardFeature,
"timer-actions": supportsTimerActionsCardFeature,
"timer-presets": supportsTimerPresetsCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,
@@ -1,91 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
import type {
TimerActionsCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import {
DEFAULT_TIMER_ACTIONS,
TIMER_ACTIONS,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-timer-actions-card-feature-editor")
export class HuiTimerActionsCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: TimerActionsCardFeatureConfig;
public setConfig(config: TimerActionsCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne((localize: LocalizeFunc) =>
customizableListSchema({
field: "actions",
options: TIMER_ACTIONS.map((action) => ({
value: action,
label: localize(
`ui.panel.lovelace.editor.features.types.timer-actions.actions_list.${action}`
),
})),
})
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const data = customizableListData(this._config, "actions");
const schema = this._schema(this.hass.localize);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
const config = processCustomizableListValue<TimerActionsCardFeatureConfig>(
ev.detail.value,
"actions",
DEFAULT_TIMER_ACTIONS
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.timer-actions.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-timer-actions-card-feature-editor": HuiTimerActionsCardFeatureEditor;
}
}
@@ -1,235 +0,0 @@
import { mdiDelete, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { createDurationData } from "../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { uid } from "../../../../common/util/uid";
import "../../../../components/ha-button";
import "../../../../components/ha-duration-input";
import type { HaDurationData } from "../../../../components/ha-duration-input";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import {
durationDataToTimerString,
normalizeTimerDuration,
} from "../../../../data/timer";
import type { HomeAssistant } from "../../../../types";
import type {
LovelaceCardFeatureContext,
TimerPresetsCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
const NEW_PRESET = "0:05:00";
@customElement("hui-timer-presets-card-feature-editor")
export class HuiTimerPresetsCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: TimerPresetsCardFeatureConfig;
// Stable key per row so rows keep their DOM (and input focus) while other
// rows are added, removed, or dragged. Presets are plain strings, so keys
// are tracked in a parallel array, mirroring ha-input-multi.
@state() private _keys: string[] = [];
public setConfig(config: TimerPresetsCardFeatureConfig): void {
this._config = config;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_config")) {
const length = this._config?.presets?.length ?? 0;
if (this._keys.length !== length) {
this._keys = Array.from(
{ length },
(_, index) => this._keys[index] ?? uid()
);
}
}
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "style",
selector: {
select: {
multiple: false,
mode: "list",
options: ["buttons", "dropdown"].map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.features.types.timer-presets.style_list.${mode}`
),
})),
},
},
},
] as const
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const presets = this._config.presets ?? [];
const data: TimerPresetsCardFeatureConfig = {
style: "buttons",
...this._config,
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._formChanged}
></ha-form>
<ha-sortable
handle-selector=".handle"
draggable-selector=".preset"
@item-moved=${this._presetMoved}
>
<div class="presets">
${repeat(
presets,
(_preset, index) => this._keys[index],
(preset, index) => html`
<div class="preset">
<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<ha-duration-input
.data=${this._presetDurationData(preset)}
.index=${index}
required
@value-changed=${this._presetChanged}
></ha-duration-input>
<ha-icon-button
.path=${mdiDelete}
.index=${index}
.label=${this.hass!.localize("ui.common.remove")}
@click=${this._removePreset}
></ha-icon-button>
</div>
`
)}
</div>
</ha-sortable>
<ha-button appearance="plain" @click=${this._addPreset}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.common.add")}
</ha-button>
`;
}
private _formChanged(ev: CustomEvent): void {
ev.stopPropagation();
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.timer-presets.${schema.name}`
);
// Normalize before binding so numeric presets ({seconds: 3600}) show as
// 1:00:00 instead of overflowing the seconds field, where the input's
// carry-over logic would corrupt the value on the next edit.
private _presetDurationData(
preset: string | number
): HaDurationData | undefined {
const durationData = createDurationData(preset);
return durationData ? normalizeTimerDuration(durationData) : undefined;
}
private _presetChanged(ev: CustomEvent): void {
ev.stopPropagation();
const index = (ev.currentTarget as any).index as number;
const value = ev.detail.value as HaDurationData | undefined;
const presets = [...(this._config!.presets ?? [])];
presets[index] = durationDataToTimerString(value ?? {});
this._updatePresets(presets);
}
private _presetMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const presets = [...(this._config!.presets ?? [])];
const [moved] = presets.splice(oldIndex, 1);
presets.splice(newIndex, 0, moved);
// Move the row's key with it so its DOM identity is preserved.
const keys = [...this._keys];
const [movedKey] = keys.splice(oldIndex, 1);
keys.splice(newIndex, 0, movedKey);
this._keys = keys;
this._updatePresets(presets);
}
private _removePreset(ev: Event): void {
const index = (ev.currentTarget as any).index as number;
this._keys = this._keys.filter((_, i) => i !== index);
const presets = [...(this._config!.presets ?? [])];
presets.splice(index, 1);
this._updatePresets(presets);
}
private _addPreset(): void {
this._keys = [...this._keys, uid()];
this._updatePresets([...(this._config!.presets ?? []), NEW_PRESET]);
}
private _updatePresets(presets: (string | number)[]): void {
fireEvent(this, "config-changed", {
config: { ...this._config!, presets },
});
}
static styles = css`
ha-form {
display: block;
margin-bottom: var(--ha-space-4);
}
.preset {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-bottom: var(--ha-space-2);
}
.preset ha-duration-input {
flex: 1;
}
.handle {
cursor: grab;
padding: var(--ha-space-2);
margin: calc(var(--ha-space-2) * -1);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-timer-presets-card-feature-editor": HuiTimerPresetsCardFeatureEditor;
}
}
@@ -9,13 +9,14 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-list";
import "../../../../components/ha-radio-list-item";
import "../../../../components/ha-select";
import "../../../../components/ha-spinner";
import "../../../../components/ha-dialog";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { fetchConfig } from "../../../../data/lovelace/config/types";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
import { getDefaultPanelUrlPath } from "../../../../data/panel";
import { LOVELACE_PANEL } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { SelectViewDialogParams } from "./show-select-view-dialog";
@@ -42,11 +43,15 @@ export class HuiDialogSelectView extends LitElement {
@state() private _selectedViewIdx = 0;
@state() private _loading = false;
@state() private _open = false;
public showDialog(params: SelectViewDialogParams): void {
this._config = params.lovelaceConfig;
this._urlPath = params.urlPath;
this._selectedViewIdx = 0;
this._loading = false;
this._params = params;
this._open = true;
if (this._params.allowDashboardChange) {
@@ -68,8 +73,6 @@ export class HuiDialogSelectView extends LitElement {
return nothing;
}
const defaultPanel = getDefaultPanelUrlPath(this.hass);
return html`
<ha-dialog
.open=${this._open}
@@ -86,19 +89,19 @@ export class HuiDialogSelectView extends LitElement {
"ui.panel.lovelace.editor.select_view.dashboard_label"
)}
.disabled=${!this._dashboards.length}
.value=${this._urlPath || defaultPanel}
.value=${this._urlPath ?? LOVELACE_PANEL}
@selected=${this._dashboardChanged}
autofocus
.options=${this._dashboards
.map((dashboard) => ({
value: dashboard.url_path,
label: `${dashboard.title}${dashboard.id === "lovelace" ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
label: `${dashboard.title}${dashboard.id === LOVELACE_PANEL ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
disabled: dashboard.mode !== "storage",
}))
.sort((a, b) =>
a.value === "lovelace"
a.value === LOVELACE_PANEL
? -1
: b.value === "lovelace"
: b.value === LOVELACE_PANEL
? 1
: a.label.localeCompare(b.label)
)}
@@ -106,54 +109,7 @@ export class HuiDialogSelectView extends LitElement {
</ha-select>`
: nothing
}
${
!this._config || (this._config.views || []).length < 1
? html`<ha-alert alert-type="error"
>${this.hass.localize(
this._config
? "ui.panel.lovelace.editor.select_view.no_views"
: "ui.panel.lovelace.editor.select_view.no_config"
)}</ha-alert
>`
: this._config.views.length > 1
? html`
<ha-list>
${this._config.views.map((view, idx) => {
const isStrategy = isStrategyView(view);
return html`
<ha-radio-list-item
.graphic=${
this._config?.views.some(({ icon }) => icon)
? "icon"
: nothing
}
@click=${this._viewChanged}
.value=${idx.toString()}
.selected=${this._selectedViewIdx === idx}
.disabled=${
isStrategy && !this._params?.includeStrategyViews
}
?autofocus=${
idx === 0 && !this._params!.allowDashboardChange
}
>
<span>
${view.title}${
isStrategy
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
: nothing
}
</span>
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
</ha-radio-list-item>
`;
})}
</ha-list>
`
: nothing
}
${this._renderViews()}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@@ -164,7 +120,7 @@ export class HuiDialogSelectView extends LitElement {
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._config || (this._config.views || []).length < 1}
.disabled=${!this._selectableConfig}
@click=${this._selectView}
>
${this._params.actionLabel || this.hass!.localize("ui.common.move")}
@@ -174,30 +130,97 @@ export class HuiDialogSelectView extends LitElement {
`;
}
// While a config is loading the views on screen still belong to the
// previously selected dashboard, so nothing may be picked from them.
private get _selectableConfig(): LovelaceConfig | undefined {
return !this._loading && this._config?.views?.length
? this._config
: undefined;
}
private _renderViews() {
if (this._loading) {
return html`<div class="loading">
<ha-spinner size="medium"></ha-spinner>
</div>`;
}
if (!this._selectableConfig) {
return html`<ha-alert alert-type="error">
${this.hass.localize(
this._config
? "ui.panel.lovelace.editor.select_view.no_views"
: "ui.panel.lovelace.editor.select_view.no_config"
)}
</ha-alert>`;
}
const views = this._selectableConfig.views;
if (views.length < 2) {
return nothing;
}
const hasIcon = views.some(({ icon }) => icon);
return html`
<ha-list>
${views.map((view, idx) => {
const isStrategy = isStrategyView(view);
return html`
<ha-radio-list-item
.graphic=${hasIcon ? "icon" : nothing}
@click=${this._viewChanged}
.value=${idx.toString()}
.selected=${this._selectedViewIdx === idx}
.disabled=${isStrategy && !this._params?.includeStrategyViews}
?autofocus=${idx === 0 && !this._params!.allowDashboardChange}
>
<span>
${view.title}${
isStrategy
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
: nothing
}
</span>
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
</ha-radio-list-item>
`;
})}
</ha-list>
`;
}
private async _getDashboards() {
this._dashboards =
this._params!.dashboards || (await fetchDashboards(this.hass));
}
private async _dashboardChanged(ev: ValueChangedEvent<string>) {
let urlPath: string | null = ev.detail.value;
const urlPath = ev.detail.value === LOVELACE_PANEL ? null : ev.detail.value;
if (urlPath === this._urlPath) {
return;
}
if (urlPath === "lovelace") {
urlPath = null;
}
this._urlPath = urlPath;
this._selectedViewIdx = 0;
this._loading = true;
let config: LovelaceConfig | undefined;
try {
this._config = (await fetchConfig(
config = (await fetchConfig(
this.hass.connection,
urlPath,
false
)) as LovelaceConfig;
} catch (_err: any) {
this._config = undefined;
config = undefined;
}
// Responses can resolve out of order.
if (urlPath !== this._urlPath) {
return;
}
this._config = config;
this._selectedViewIdx = 0;
this._loading = false;
}
private _viewChanged(e) {
@@ -209,10 +232,14 @@ export class HuiDialogSelectView extends LitElement {
}
private _selectView(): void {
const config = this._selectableConfig;
if (!config) {
return;
}
fireEvent(this, "view-selected", { view: this._selectedViewIdx });
this._params!.viewSelectedCallback(
this._urlPath!,
this._config!,
config,
this._selectedViewIdx
);
this.closeDialog();
@@ -225,6 +252,10 @@ export class HuiDialogSelectView extends LitElement {
ha-select {
width: 100%;
}
.loading {
display: flex;
justify-content: center;
}
mwc-radio-list-item {
direction: ltr;
}
@@ -17,7 +17,7 @@ import type { HomeAssistant } from "../../../../types";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../../data/media_source";
export interface BackgroundConfigTarget {
@@ -142,21 +142,35 @@ export class HuiViewBackgroundEditor extends LitElement {
`${(background.opacity ?? 100) / 100}`
);
const backgroundImage =
typeof background.image === "object"
? background.image.media_content_id
: background.image;
const backgroundImage = this._currentBackgroundImage();
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this._resolvedImage = result.url;
});
resolveMediaSourceWithCache(this.hass, backgroundImage).then(
(result) => {
// Discard if the image changed while resolving
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = result.url;
}
},
() => {
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = undefined;
}
}
);
} else {
this._resolvedImage = backgroundImage;
}
}
}
private _currentBackgroundImage(): string | undefined {
const background = this._backgroundData(this._config);
return typeof background.image === "object"
? background.image.media_content_id
: background.image;
}
protected render() {
if (!this.hass) {
return nothing;
@@ -5,7 +5,7 @@ import type { HomeAssistant } from "../../../types";
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../data/media_source";
@customElement("hui-view-background")
@@ -21,20 +21,37 @@ export class HUIViewBackground extends LitElement {
return nothing;
}
private _fetchMedia() {
const backgroundImage =
typeof this.background === "string"
? this.background
: typeof this.background?.image === "object"
? this.background.image.media_content_id
: this.background?.image;
private _getBackgroundImage(
background?: string | LovelaceViewBackgroundConfig
): string | undefined {
if (typeof background === "string") {
return background;
}
if (typeof background?.image === "object") {
return background.image.media_content_id;
}
return background?.image;
}
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this.resolvedImage = result.url;
});
} else {
private async _fetchMedia() {
const backgroundImage = this._getBackgroundImage(this.background);
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
this.resolvedImage = undefined;
return;
}
let resolvedUrl: string | undefined;
try {
resolvedUrl = (
await resolveMediaSourceWithCache(this.hass, backgroundImage)
).url;
} catch {
resolvedUrl = undefined;
}
// Discard if the background changed while resolving
if (this._getBackgroundImage(this.background) === backgroundImage) {
this.resolvedImage = resolvedUrl;
}
}
@@ -73,10 +90,7 @@ export class HUIViewBackground extends LitElement {
background?: string | LovelaceViewBackgroundConfig
) {
if (typeof background === "object" && background.image) {
const image =
typeof background.image === "object"
? background.image.media_content_id || ""
: background.image;
const image = this._getBackgroundImage(background) || "";
if (isMediaSourceContentId(image) && !this.resolvedImage) {
return null;
}
+48 -16
View File
@@ -1,9 +1,8 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { TimerRemainingTimeController } from "../common/controllers/timer-remaining-time-controller";
import { computeDisplayTimer } from "../data/timer";
import { customElement, property, state } from "lit/decorators";
import { computeDisplayTimer, timerTimeRemaining } from "../data/timer";
import type { HomeAssistant } from "../types";
@customElement("ha-timer-remaining-time")
@@ -12,27 +11,60 @@ class HaTimerRemainingTime extends ReactiveElement {
@property({ attribute: false }) public stateObj!: HassEntity;
private _remainingTime = new TimerRemainingTimeController(this);
@state() private timeRemaining?: number;
private _updateRemaining: any;
protected createRenderRoot() {
return this;
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("stateObj")) {
this._remainingTime.setStateObj(this.stateObj);
}
}
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
this.innerHTML =
computeDisplayTimer(
this.hass.formatEntityState,
this.stateObj,
this._remainingTime.timeRemaining
) ?? "-";
computeDisplayTimer(this.hass, this.stateObj, this.timeRemaining) ?? "-";
}
connectedCallback() {
super.connectedCallback();
if (this.stateObj) {
this._startInterval(this.stateObj);
}
}
disconnectedCallback() {
super.disconnectedCallback();
this._clearInterval();
}
protected willUpdate(changedProp: PropertyValues<this>): void {
super.willUpdate(changedProp);
if (changedProp.has("stateObj")) {
this._startInterval(this.stateObj);
}
}
private _clearInterval() {
if (this._updateRemaining) {
clearInterval(this._updateRemaining);
this._updateRemaining = null;
}
}
private _startInterval(stateObj: HassEntity) {
this._clearInterval();
this._calculateRemaining(stateObj);
if (stateObj.state === "active") {
this._updateRemaining = setInterval(
() => this._calculateRemaining(this.stateObj),
1000
);
}
}
private _calculateRemaining(stateObj: HassEntity) {
this.timeRemaining = timerTimeRemaining(stateObj);
}
}
+46 -53
View File
@@ -325,11 +325,8 @@
"pause": "Pause",
"cancel": "Cancel",
"finish": "Finish",
"restart": "Restart",
"set": "Set"
},
"presets": "Presets",
"start_preset": "Start {duration}"
}
},
"vacuum": {
"actions": {
@@ -805,8 +802,10 @@
"device_not_found": "Device not found",
"entity_not_found": "Entity not found",
"label_not_found": "Label not found",
"device_replaced_headline": "Replaced device",
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"replace_device": "Replace",
"device_replaced_by_one": "Replaced by {device}",
"replace_update": "Update",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"entities_count_filtered": "{count}/{total} {total, plural,\n one {entity}\n other {entities}\n}",
@@ -927,16 +926,24 @@
"no_area": "No area",
"placeholder": "Select a device",
"unknown": "Unknown device selected",
"device_replaced_count": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"device_replaced_by_one": "This device was replaced by {device}.",
"device_replaced_by_multiple": "This device was replaced by {count} devices.",
"replace_device": "Replace",
"device_replaced": "Replaced device",
"device_replaced_by_one": "This device no longer exists. It was replaced by {device}.",
"device_replaced_by_one_integration": "This device no longer exists. It was replaced by {device} from {integration}.",
"device_replaced_by_multiple": "This device no longer exists. It was replaced by {count} devices, pick the one to use here.",
"replace_update": "Update",
"replace_choose": "Choose",
"replaced_dialog": {
"title": "Replace device",
"description": "This device was replaced by multiple devices. Choose which one to use.",
"recommended": "Recommended"
}
},
"device-class-picker": {
"device_class": "Device class",
"add": "Add device class",
"no_device_classes": "No device classes available",
"no_match": "No device classes found for {term}"
},
"category-picker": {
"clear": "Clear",
"show_categories": "Show categories",
@@ -1677,10 +1684,9 @@
"person": "Edit person"
},
"details": "Details",
"context": "Context",
"entity": "Entity",
"floor": "Floor",
"entity_id": "Entity ID",
"copy_value": "Copy {label}: {value}",
"labels": "Labels",
"toggle_yaml_mode": "Toggle YAML mode",
"translated": "Translated",
@@ -6432,12 +6438,25 @@
"description_features": "Control home when away and integrate with Alexa and Google Assistant",
"login": {
"title": "Cloud login",
"introduction": "Home Assistant Cloud provides you with a secure remote connection to your instance while away from home. It also allows you to connect with cloud-only services: Amazon Alexa and Google Assistant.",
"introduction2": "This service is run by our partner ",
"introduction2a": ", a company founded by the founders of Home Assistant.",
"introduction3": "Home Assistant Cloud is a subscription service with a free one-month trial. No payment information necessary.",
"learn_more_link": "Learn more about Home Assistant Cloud",
"landing_title": "[%key:ui::panel::config::cloud::account::onboarding::ha_cloud_title%]",
"hero_title": "Tools for your home, support for the Open Home",
"hero_lead": "The easy, secure, and private way to get more out of Home Assistant, and the simplest way to fund its development.",
"trial_note": "Try free for a month. No payment information required up front.",
"partner_note": "This service is run by our partner {nabu_casa_link}, a company founded by the founders of Home Assistant.",
"feature_remote_title": "Access from anywhere",
"feature_remote_body": "Securely and privately reach your Home Assistant when you're away from home. Zero router configuration, port forwarding, or complex VPNs.",
"feature_backup_title": "Backups kept safe",
"feature_backup_body": "Your latest Home Assistant backup, automatically stored somewhere safe and only accessible with your private key, ready to restore your full system with one click.",
"feature_voice_control_title": "Alexa and Google Home made simple",
"feature_voice_control_body": "Ease your transition to Home Assistant, use your Google and Alexa speakers to control every device. Set up in minutes, not hours.",
"feature_voice_quality_title": "Fast, accurate voice control",
"feature_voice_quality_body": "More languages and higher accuracy for your own private voice assistant powered by Home Assistant.",
"feature_companion_title": "Secure app connectivity",
"feature_companion_body": "The Home Assistant app on your mobile device forms a secure connection home, encrypting your location and sensor data.",
"feature_support_title": "Support Home Assistant",
"feature_support_body": "Your subscription directly funds the Open Home Foundation, keeping Home Assistant independent and free from ads or data harvesting.",
"sign_in": "Sign in",
"sign_in_lead": "Sign in to your Nabu Casa account.",
"email": "Email",
"email_error_msg": "Invalid email",
"password": "Password",
@@ -6447,15 +6466,11 @@
"cancel": "Cancel",
"submit": "Submit",
"forgot_password": "Forgot password?",
"start_trial": "Start your free 1-month trial",
"trial_info": "No payment information necessary",
"alert_password_change_required": "You need to change your password before logging in.",
"alert_email_confirm_necessary": "You need to confirm your email before logging in.",
"alert_mfa_code_required": "You need to enter your two-factor authentication code.",
"alert_mfa_expired_or_not_started": "Multi-factor authentication expired, or not started. Please try again.",
"alert_totp_code_invalid": "Invalid two-factor authentication code.",
"cloud_pipeline_title": "Want to use Home Assistant Cloud for your voice assistant?",
"cloud_pipeline_text": "We created a new assistant for you, using the superior text-to-speech and speech-to-text engines from Home Assistant Cloud. Would you like to set this assistant as the preferred assistant?"
"alert_totp_code_invalid": "Invalid two-factor authentication code."
},
"forgot_password": {
"title": "Forgot password",
@@ -6467,17 +6482,9 @@
"check_your_email": "Check your email for instructions on how to reset your password."
},
"register": {
"title": "Register account",
"headline": "Start your free trial",
"headline": "Start free trial",
"information": "Create an account to start your free one-month trial with Home Assistant Cloud. No payment information necessary.",
"information2": "The trial will give you access to all the benefits of Home Assistant Cloud, including:",
"feature_remote_control": "Control of Home Assistant away from home",
"feature_google_home": "Integration with Google Assistant",
"feature_amazon_alexa": "Integration with Amazon Alexa",
"feature_webhook_apps": "Easy integration with webhook-based apps like OwnTracks",
"information3": "This service is run by our partner ",
"information3a": ", a company founded by the founders of Home Assistant.",
"information4": "By registering an account, you agree to the following terms and conditions.",
"terms_notice": "By creating an account, you agree to our {terms} and {privacy_policy}.",
"link_terms_conditions": "Terms and conditions",
"link_privacy_policy": "Privacy policy",
"create_account": "Create account",
@@ -6486,10 +6493,17 @@
"password": "Password",
"password_error_msg": "Passwords are at least 8 characters",
"start_trial": "Start trial",
"sign_in_instead": "Sign in instead",
"check_your_email": "Check your email",
"confirm_email_body": "Open the confirmation link in the email we just sent to {email}.",
"waiting_for_confirmation": "Waiting for confirmation. We'll sign you in automatically.",
"checking_confirmation": "Checking for your confirmation…",
"auto_login_failed": "We couldn't sign you in automatically. Confirm your email, then sign in.",
"nothing_arrived": "Nothing arrived? Check your spam folder, or {resend_link}.",
"resend_link": "resend the confirmation email",
"resend_confirm_email": "Resend confirmation email",
"clicked_confirm": "I opened the confirmation link",
"confirm_email": "Check the email we just sent to {email} and open the confirmation link to continue.",
"account_created": "Account created! Check your email for instructions on how to activate your account.",
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
},
"account": {
@@ -10866,25 +10880,6 @@
"reset": "Reset"
}
},
"timer-actions": {
"label": "Timer actions",
"customize": "[%key:ui::panel::lovelace::editor::features::types::counter-actions::customize%]",
"actions": "[%key:ui::panel::lovelace::editor::features::types::counter-actions::actions%]",
"actions_list": {
"start": "[%key:ui::card::timer::actions::start%]",
"pause": "[%key:ui::card::timer::actions::pause%]",
"cancel": "[%key:ui::card::timer::actions::cancel%]",
"finish": "[%key:ui::card::timer::actions::finish%]"
}
},
"timer-presets": {
"label": "Timer presets",
"style": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style%]",
"style_list": {
"buttons": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::buttons%]",
"dropdown": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style_list::dropdown%]"
}
},
"fan-preset-modes": {
"label": "Fan preset modes",
"style": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style%]",
@@ -11775,8 +11770,6 @@
"cancel": "[%key:ui::panel::config::cloud::login::cancel%]",
"submit": "[%key:ui::panel::config::cloud::login::submit%]",
"forgot_password": "[%key:ui::panel::config::cloud::login::forgot_password%]",
"start_trial": "[%key:ui::panel::config::cloud::login::start_trial%]",
"trial_info": "[%key:ui::panel::config::cloud::login::trial_info%]",
"alert_password_change_required": "[%key:ui::panel::config::cloud::login::alert_password_change_required%]",
"alert_email_confirm_necessary": "[%key:ui::panel::config::cloud::login::alert_email_confirm_necessary%]",
"alert_mfa_code_required": "[%key:ui::panel::config::cloud::login::alert_mfa_code_required%]",
+30
View File
@@ -17,10 +17,24 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
return points;
};
// The chart data modules break the line with a null value. A handful of them
// stands for an entity that went unavailable; a series that is mostly null
// stands for the climate heating dataset, which emits one per inactive state.
const withGaps = (
points: [number, number][],
isGap: (index: number) => boolean
): [number, number | null][] =>
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
const small = generatePoints(1, SCALES.small);
const medium = generatePoints(2, SCALES.medium);
const large = generatePoints(3, SCALES.large);
const largeObjects = large.map((value) => ({ value }));
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
const largeMostlyGaps = withGaps(
large,
(index) => Math.floor(index / 50) % 3 !== 0
);
describe("downSampleLineData", () => {
bench("min/max small (1k points)", () => {
@@ -54,4 +68,20 @@ describe("downSampleLineData", () => {
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large with a few gaps (100k points)",
() => {
downSampleLineData(largeFewGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large mostly gaps (100k points)",
() => {
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
});
+349
View File
@@ -0,0 +1,349 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LeafletModuleType } from "../../../src/common/dom/setup-leaflet-map";
// The fallback to raster tiles is what keeps the map working on devices
// without WebGL2, on instances that cannot load the MapLibre chunk, and when
// building the MapLibre map itself fails - a blocked worker, an exhausted
// WebGL context budget. None of that is reachable from the `ha-map` tests,
// which run in jsdom and therefore only ever take the raster branch.
const maplibreLayer = vi.hoisted(() => ({
addTo: vi.fn(),
remove: vi.fn(),
options: {} as { attribution?: string },
getMaplibreMap: vi.fn(),
}));
const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
const STYLE = {
version: 8,
sources: {},
layers: [],
sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }],
};
const rasterLayer = { addTo: vi.fn() };
// Kept as a local so tests can flip it: @types/leaflet has it readonly.
const browser = { retina: false };
const leaflet = {
tileLayer: vi.fn(() => rasterLayer),
Browser: browser,
} as unknown as LeafletModuleType;
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
const glHandlers: Record<string, () => void> = {};
const glMap = {
setStyle: vi.fn(),
on: vi.fn((event: string, handler: () => void) => {
glHandlers[event] = handler;
}),
};
const map = { on: vi.fn() } as any;
// The WebGL2 probe is cached for the lifetime of the module, so every test
// needs its own copy of it.
const setWebGL2 = async (supported: boolean) => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() =>
supported ? ({ getExtension: () => null } as any) : null
);
return (await import("../../../src/common/map/base-layer")).createBaseLayer;
};
const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
maplibreLayer.options = {};
maplibreGL.mockReturnValue(maplibreLayer);
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
maplibreLayer.getMaplibreMap.mockReturnValue(glMap);
for (const event of Object.keys(glHandlers)) {
delete glHandlers[event];
}
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("createBaseLayer", () => {
it("falls back to raster tiles without WebGL2", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
// No referrer: the only one a browser can send is its origin, which
// identifies a Nabu Casa installation. The fallback source is chosen so it
// does not need one.
expect(options).not.toHaveProperty("referrerPolicy");
// The vector layer takes its credit from the style's source instead, so the
// raster layer is the only one carrying attribution itself - and it credits
// both the data and whoever rendered it.
expect(options.attribution).toContain("openstreetmap.org/copyright");
expect(options.attribution).toContain("carto.com/attributions");
expect(url).toMatch(/\{z\}\/\{x\}\/\{y\}/);
});
// The devices on the fallback are the old retina tablets, and the source
// serves @2x, so they get sharp tiles without quadrupling the requests.
it("asks for @2x raster tiles on a retina screen", async () => {
const createBaseLayer = await setWebGL2(false);
browser.retina = true;
await createBaseLayer(leaflet, map, false);
expect(vi.mocked(leaflet.tileLayer).mock.calls[0][0]).toContain("@2x.png");
browser.retina = false;
});
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
expect(maplibreGL).toHaveBeenCalledOnce();
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
it("falls back to raster tiles when the style cannot be fetched", async () => {
const createBaseLayer = await setWebGL2(true);
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("offline");
})
);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
// The plugin only builds the MapLibre map once the layer is added, so this
// is where a blocked worker or a refused WebGL context surfaces.
it("falls back to raster tiles when adding the vector layer throws", async () => {
const createBaseLayer = await setWebGL2(true);
maplibreLayer.addTo.mockImplementation(() => {
throw new Error("Failed to initialize WebGL");
});
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreLayer.remove).toHaveBeenCalled();
});
it("still falls back when tearing down the half-added layer throws", async () => {
const createBaseLayer = await setWebGL2(true);
maplibreLayer.addTo.mockImplementation(() => {
throw new Error("Failed to initialize WebGL");
});
maplibreLayer.remove.mockImplementation(() => {
throw new Error("nothing to remove");
});
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
});
describe("setDarkMode", () => {
beforeEach(() => {
glMap.setStyle.mockClear();
});
it("swaps the style, and ignores a repeat of the current mode", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
baseLayer.setDarkMode(true);
expect(glMap.setStyle).toHaveBeenCalledOnce();
});
// A failed request must roll back to the style that is actually on screen.
// Rolling back to the opposite of the failed request instead would desync
// the tracked mode, and the next toggle to that mode would do nothing.
it("can retry a mode whose request failed while another was in flight", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false);
const failing = vi.fn(async () => {
throw new Error("offline");
});
vi.stubGlobal("fetch", failing);
// Dark is superseded by light, which then fails: the map is still light.
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
await vi.waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
// Both rejections have to land before the retry, or this passes whatever
// the rollback does.
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
});
// Styles are fetched, so a burst of theme changes can resolve out of order.
// Applying a stale one would leave the map on the wrong theme for good.
it("ignores a style that resolves after a newer request", async () => {
const createBaseLayer = await setWebGL2(true);
const resolvers: ((value: unknown) => void)[] = [];
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise((resolve) => {
resolvers.push(resolve);
})
)
);
const styleResponse = (name: string) => ({
json: async () => ({ ...structuredClone(STYLE), name }),
});
// The layer only settles once its first style resolves, so let that one
// through before the map exists to switch.
const pending = createBaseLayer(leaflet, map, false);
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
resolvers.shift()!(styleResponse("light"));
const baseLayer = await pending;
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
await vi.waitFor(() => expect(resolvers).toHaveLength(2));
// The dark request, which is no longer the newest, comes back last.
resolvers[1](styleResponse("light"));
resolvers[0](styleResponse("dark"));
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
expect(glMap.setStyle.mock.calls[0][0]).toMatchObject({ name: "light" });
});
});
// Browsers cap the number of live WebGL contexts and drop the oldest. With
// more map cards than that cap - measured at 16 in Chrome - the first cards
// lose their context and never get it back, because nothing frees a slot.
describe("WebGL context loss", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("falls back to raster tiles when the context stays lost", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(maplibreLayer.remove).toHaveBeenCalled();
expect(isRaster()).toBe(true);
});
it("keeps the vector layer when the context comes back", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
vi.runAllTimers();
expect(maplibreLayer.remove).not.toHaveBeenCalled();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
// Backgrounding a tab loses the context too, and there it comes back when
// the page is shown again. Running the clock anyway would mean switching
// apps for a few seconds was enough to come back to a raster map.
it("waits for the page to be visible before falling back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(isRaster()).toBe(true);
});
it("keeps the vector layer when a hidden page gets its context back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
it("stops listening for visibility once it has fallen back", async () => {
const remove = vi.spyOn(document, "removeEventListener");
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(remove).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function)
);
// And a later visibility change must not add a second raster layer.
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(isRaster()).toBe(true);
});
it("stops answering theme changes once it has fallen back", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
baseLayer.setDarkMode(true);
expect(glMap.setStyle).not.toHaveBeenCalled();
});
});
+204 -6
View File
@@ -19,9 +19,52 @@ const generatePoints = (
return points;
};
const toObjectPoints = (points: [number, number][]) =>
// Gap markers: the chart data modules push a null value to break the line
// where an entity was unavailable.
type GappedPoint = [number, number | null | undefined];
const toObjectPoints = (points: GappedPoint[]) =>
points.map((value) => ({ value }));
const expectXOrdered = (result: { [0]: number }[]) => {
for (let i = 1; i < result.length; i++) {
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
}
};
// A series whose readings are all positive, with an unavailable stretch that
// starts inside the first frame. Mirrors the point sequence
// state-history-chart-line-data.ts emits for a gap.
const gappedPoints: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90], // frame maximum
[FIXED_EPOCH_MS + 2_000, 60],
[FIXED_EPOCH_MS + 3_000, 10], // frame minimum
[FIXED_EPOCH_MS + 4_000, 20], // last reading before the gap
[FIXED_EPOCH_MS + 4_001, null], // gap marker
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
// A generated series with three unavailable stretches of different lengths.
const generateGappedPoints = (seed: number, count: number) => {
const points: GappedPoint[] = generatePoints(seed, count);
for (const [start, length] of [
[Math.floor(count * 0.13), 3],
[Math.floor(count * 0.4), 25],
[Math.floor(count * 0.83), 1],
]) {
const gapStart = points[start][0];
points.splice(
start + 1,
length,
[gapStart + 1, points[start][1]],
[gapStart + 1, null]
);
}
return points;
};
describe("downSampleLineData", () => {
it("returns empty array for undefined data", () => {
expect(downSampleLineData(undefined, 100)).toEqual([]);
@@ -66,11 +109,7 @@ describe("downSampleLineData", () => {
});
it("min/max mode preserves x-order for sorted input", () => {
const points = generatePoints(4, 1000);
const result = downSampleLineData(points, 50);
for (let i = 1; i < result.length; i++) {
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
}
expectXOrdered(downSampleLineData(generatePoints(4, 1000), 50));
});
it("min/max mode matches characterization snapshot", () => {
@@ -193,6 +232,165 @@ describe("downSampleLineData", () => {
).toMatchSnapshot();
});
it("keeps the frame minimum when a gap marker shares the frame", () => {
// Without special handling the marker becomes y=0, wins the minimum slot
// and the real minimum (10) is dropped.
expect(downSampleLineData(gappedPoints, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 3_000, 10],
[FIXED_EPOCH_MS + 4_001, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("drops a marker whose gap closes within the same frame", () => {
// A frame spans about one device pixel, so a gap that opens and closes
// inside one is too narrow to show. The values around it stay.
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 10], // frame minimum, before the marker
[FIXED_EPOCH_MS + 1_001, null],
[FIXED_EPOCH_MS + 2_000, 90], // frame maximum, after the marker
[FIXED_EPOCH_MS + 3_000, 60],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 10],
[FIXED_EPOCH_MS + 2_000, 90],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a marker sharing its x with a value after that value", () => {
// statistics-chart-data.ts ends the line and breaks it at the same x
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 2_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 2_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a gap marker whose frame holds no values", () => {
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 60],
[FIXED_EPOCH_MS + 15_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 15_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a single gap marker per frame", () => {
// A run of nulls inside one frame renders the same as a single null: the
// break only depends on which points the marker sits between.
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 3_000, null],
[FIXED_EPOCH_MS + 4_000, null],
[FIXED_EPOCH_MS + 5_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 5_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("bounds the output on a series where most points are null", () => {
// The climate heating/cooling datasets push a null for every state where
// the mode is inactive, so markers must not escape the frame budget.
const values = generatePoints(20, SCALES.medium);
const random = createSeededRandom(21);
const gapped: GappedPoint[] = values.map(([x, y]) =>
random() < 0.65 ? [x, null] : [x, y]
);
const gapless = downSampleLineData(values, 500);
const result = downSampleLineData(gapped, 500);
// Both series share an x grid, so they share frames, and the gapless one
// emits at least one point per frame. A gapped frame emits at most three:
// min, max and a single marker.
expect(result.length).toBeLessThanOrEqual(3 * gapless.length);
expect(
result.filter((point) => point[1] === null).length
).toBeLessThanOrEqual(gapless.length);
});
it("handles gap markers on object-shaped points", () => {
const points = toObjectPoints(gappedPoints);
expect(downSampleLineData(points, 3)).toEqual([
points[1],
points[3],
points[5],
points[6],
points[7],
]);
});
it("handles gap markers on Date x values", () => {
// statistics charts use Date objects for x
const points = gappedPoints.map(
([x, y]) => [new Date(x), y] as [Date, number | null | undefined]
);
expect(downSampleLineData(points, 3)).toEqual([
points[1],
points[3],
points[5],
points[6],
points[7],
]);
});
it("mean mode leaves gap markers out of the average", () => {
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 10],
[FIXED_EPOCH_MS + 1_000, 20],
[FIXED_EPOCH_MS + 1_001, null],
[FIXED_EPOCH_MS + 2_000, 30],
[FIXED_EPOCH_MS + 30_000, 100],
[FIXED_EPOCH_MS + 31_000, 100],
];
expect(downSampleLineData(points, 3, undefined, undefined, true)).toEqual([
// (10 + 20 + 30) / 3, not (10 + 20 + 0 + 30) / 4
[FIXED_EPOCH_MS + 1_000, 20],
[FIXED_EPOCH_MS + 30_500, 100],
]);
});
it("min/max mode preserves x-order for gapped input", () => {
const result = downSampleLineData(generateGappedPoints(22, 1000), 50);
// Of the three gaps only the 25 point one outlasts its frame; the one and
// three point gaps close within theirs and are dropped.
expect(result.filter((point) => point[1] === null)).toHaveLength(1);
expectXOrdered(result);
});
it("large scale mean-mode digest is stable", () => {
expect(
digestResult(
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { getDeviceClassOptions } from "../../src/components/ha-device-class-picker";
import type { LocalizeFunc } from "../../src/common/translations/localize";
const localize = ((key: string) =>
key === "component.sensor.entity_component.temperature.name"
? "Temperature"
: "") as LocalizeFunc;
describe("getDeviceClassOptions", () => {
it("labels a device class with its translated name", () => {
const options = getDeviceClassOptions("sensor", localize);
expect(options).toContainEqual({
id: "temperature",
primary: "Temperature",
sorting_label: "Temperature",
});
});
it("falls back to the device class itself when it has no translated name", () => {
const options = getDeviceClassOptions("sensor", localize);
expect(options.every((option) => option.primary)).toBe(true);
expect(options).toContainEqual({
id: "humidity",
primary: "humidity",
sorting_label: "humidity",
});
});
it("returns no option for a domain without device classes", () => {
expect(getDeviceClassOptions("light", localize)).toEqual([]);
});
});
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import type { DeviceTrigger } from "../../src/data/device/device_automation";
import {
fetchReplacementDevices,
findEquivalentDeviceAutomation,
} from "../../src/data/device/device_automation";
import type { DeviceCompositeSplits } from "../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../src/data/entity/entity_registry";
import type { HomeAssistant } from "../../src/types";
const entityRegistry = [
{ id: "regid1", entity_id: "binary_sensor.one" },
{ id: "regid2", entity_id: "binary_sensor.two" },
] as EntityRegistryEntry[];
const trigger = (partial: Partial<DeviceTrigger>): DeviceTrigger =>
({
trigger: "device",
domain: "binary_sensor",
device_id: "device1",
...partial,
}) as DeviceTrigger;
describe("findEquivalentDeviceAutomation", () => {
it("picks the automation on the same entity among several of the same type", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid2" })
)
).toBe(automations[1]);
});
it("matches an entity referenced by entity id against one referenced by registry id", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "binary_sensor.two" })
)
).toBe(automations[1]);
});
it("returns undefined when the same type is only offered for another entity", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({
device_id: "device2",
type: "turned_off",
entity_id: "regid1",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid2" })
)
).toBeUndefined();
});
it("matches entity-less automations on their subtype", () => {
const automations = [
trigger({
device_id: "device2",
domain: "zha",
type: "remote_button_short_press",
subtype: "button_1",
}),
trigger({
device_id: "device2",
domain: "zha",
type: "remote_button_short_press",
subtype: "button_2",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({
domain: "zha",
type: "remote_button_short_press",
subtype: "button_2",
})
)
).toBe(automations[1]);
});
it("returns undefined when the device offers no automation of that type", () => {
const automations = [
trigger({
device_id: "device2",
type: "turned_off",
entity_id: "regid1",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid1" })
)
).toBeUndefined();
});
});
describe("fetchReplacementDevices", () => {
const hass = {
callWS: () => Promise.resolve([]),
devices: { device2: {}, device3: {} },
} as unknown as HomeAssistant;
const compositeSplits = {
removed: { split_ids: ["device2", "device3"], primary_id: "device2" },
} as unknown as DeviceCompositeSplits;
const value = trigger({
device_id: "removed",
type: "turned_on",
entity_id: "regid1",
});
it("keeps only the devices that offer the automation", async () => {
const offers = {
device2: [
trigger({
device_id: "device2",
type: "turned_on",
entity_id: "regid1",
}),
],
device3: [
trigger({
device_id: "device3",
type: "turned_on",
entity_id: "regid2",
}),
],
};
expect(
await fetchReplacementDevices(
hass,
entityRegistry,
value,
compositeSplits,
(_callWS, deviceId) => Promise.resolve(offers[deviceId])
)
).toEqual(["device2"]);
});
it("drops a device whose automations cannot be listed", async () => {
expect(
await fetchReplacementDevices(
hass,
entityRegistry,
value,
compositeSplits,
(_callWS, deviceId) =>
deviceId === "device2"
? Promise.reject(new Error("unknown device"))
: Promise.resolve([
trigger({
device_id: "device3",
type: "turned_on",
entity_id: "regid1",
}),
])
)
).toEqual(["device3"]);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
RESOLVE_CACHE_TIME,
resolveMediaSourceWithCache,
} from "../../src/data/media_source";
import type { HomeAssistant } from "../../src/types";
const CONTENT_ID = "media-source://image_upload/background";
const OTHER_CONTENT_ID = "media-source://image_upload/other";
// Core signs every resolution with a fresh timestamp, so an uncached resolve of
// the same id yields a different url each time. The mock reproduces that: the
// urls only stay equal if the resolution itself was reused.
const mockHass = () => {
let signature = 0;
return {
callWS: vi.fn(({ media_content_id }: { media_content_id: string }) => {
signature += 1;
return Promise.resolve({
url: `/api/image/serve/${media_content_id.split("/").pop()}/original?authSig=sig${signature}`,
mime_type: "image/jpeg",
});
}),
} as unknown as HomeAssistant;
};
describe("resolveMediaSourceWithCache", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("returns the same url when the same content id is resolved again", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("shares a single request between concurrent callers", async () => {
const hass = mockHass();
const [first, second] = await Promise.all([
resolveMediaSourceWithCache(hass, CONTENT_ID),
resolveMediaSourceWithCache(hass, CONTENT_ID),
]);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("resolves each content id to its own url", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
const other = await resolveMediaSourceWithCache(hass, OTHER_CONTENT_ID);
expect(first.url).toContain("/background/");
expect(other.url).toContain("/other/");
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("keeps returning the same url when hass is updated", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
// State updates replace hass with a shallow copy
const second = await resolveMediaSourceWithCache(
{ ...hass } as HomeAssistant,
CONTENT_ID
);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("does not cache failures", async () => {
const hass = mockHass();
vi.mocked(hass.callWS).mockRejectedValueOnce(new Error("unresolvable"));
await expect(
resolveMediaSourceWithCache(hass, CONTENT_ID)
).rejects.toThrowError("unresolvable");
const retried = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(retried.url).toContain("authSig=");
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("resolves a fresh url once the cached one is about to expire", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
vi.advanceTimersByTime(RESOLVE_CACHE_TIME);
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(second.url).not.toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("caches for less than the 24 hour signature validity", () => {
expect(RESOLVE_CACHE_TIME).toBeLessThan(24 * 60 * 60 * 1000);
});
});
+2 -165
View File
@@ -1,19 +1,6 @@
import { assert, describe, expect, it } from "vitest";
import { assert, describe, it } from "vitest";
import { createDurationData } from "../../src/common/datetime/create_duration_data";
import {
computeDisplayTimer,
durationDataToTimerString,
normalizeTimerDuration,
timerDurationData,
timerJustFinished,
} from "../../src/data/timer";
const timerState = (state: string, lastTransition?: string) =>
({
state,
attributes: { last_transition: lastTransition },
}) as any;
import { timerDurationData } from "../../src/data/timer";
describe("timerDurationData", () => {
it("derives the input prefill from the configured duration", () => {
@@ -56,153 +43,3 @@ describe("timerDurationData", () => {
);
});
});
describe("durationDataToTimerString", () => {
it("serializes with zero-padded minutes and seconds", () => {
assert.strictEqual(
durationDataToTimerString({ hours: 1, minutes: 5, seconds: 7 }),
"1:05:07"
);
});
it("treats missing fields as zero", () => {
assert.strictEqual(durationDataToTimerString({ minutes: 30 }), "0:30:00");
assert.strictEqual(durationDataToTimerString({}), "0:00:00");
});
it("folds days into hours", () => {
assert.strictEqual(
durationDataToTimerString({ days: 1, hours: 2 }),
"26:00:00"
);
});
it("round-trips through createDurationData", () => {
const data = createDurationData("2:15:30")!;
assert.strictEqual(durationDataToTimerString(data), "2:15:30");
});
it("normalizes out-of-range fields", () => {
assert.strictEqual(durationDataToTimerString({ seconds: 3600 }), "1:00:00");
assert.strictEqual(durationDataToTimerString({ minutes: 90 }), "1:30:00");
});
it("floors fractional seconds instead of serializing decimals", () => {
assert.strictEqual(durationDataToTimerString({ seconds: 1.5 }), "0:00:01");
assert.strictEqual(
durationDataToTimerString({ seconds: 1, milliseconds: 500 }),
"0:00:01"
);
});
});
describe("normalizeTimerDuration", () => {
it("decomposes overflowing fields into hours/minutes/seconds", () => {
assert.deepEqual(normalizeTimerDuration({ seconds: 3600 }), {
hours: 1,
minutes: 0,
seconds: 0,
});
assert.deepEqual(normalizeTimerDuration({ days: 1, hours: 2 }), {
hours: 26,
minutes: 0,
seconds: 0,
});
});
it("drops fractional seconds", () => {
assert.deepEqual(normalizeTimerDuration({ seconds: 90.9 }), {
hours: 0,
minutes: 1,
seconds: 30,
});
});
});
describe("timerJustFinished", () => {
it("detects a timer running out or being finished", () => {
expect(
timerJustFinished(timerState("active"), timerState("idle", "finished"))
).toBe(true);
expect(
timerJustFinished(timerState("paused"), timerState("idle", "finished"))
).toBe(true);
});
it("does not match a cancelled timer", () => {
expect(
timerJustFinished(timerState("active"), timerState("idle", "cancelled"))
).toBe(false);
});
it("does not match without a state transition", () => {
expect(
timerJustFinished(
timerState("idle", "finished"),
timerState("idle", "finished")
)
).toBe(false);
expect(timerJustFinished(undefined, timerState("idle", "finished"))).toBe(
false
);
});
it("does not match on older cores without last_transition", () => {
expect(timerJustFinished(timerState("active"), timerState("idle"))).toBe(
false
);
});
});
describe("computeDisplayTimer", () => {
const formatEntityState = (stateObj: any) =>
stateObj.state === "idle"
? "Idle"
: stateObj.state === "paused"
? "Paused"
: "Active";
it("shows the formatted state when idle", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "idle", attributes: {} } as any,
undefined
),
"Idle"
);
});
it("shows the formatted state when the remaining time is zero", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "active", attributes: {} } as any,
0
),
"Active"
);
});
it("shows the remaining time when active", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "active", attributes: {} } as any,
90
),
"1:30"
);
});
it("appends the formatted state when paused", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "paused", attributes: {} } as any,
90
),
"1:30 (Paused)"
);
});
});
+7 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deepActiveElement } from "../../../src/common/dom/deep-active-element";
import { nextRender } from "../../../src/common/util/render-status";
import type {
FormDialogData,
FormDialogParams,
@@ -146,8 +147,13 @@ const submit = (dialog: DialogForm) =>
const cancel = (dialog: DialogForm) =>
(getInternals(dialog)["_cancel"] as () => void)();
afterEach(() => {
afterEach(async () => {
mockForm.delayedTag = undefined;
document.body.querySelectorAll("dialog-form").forEach((el) => {
(el as DialogForm).closeDialog();
});
// Drain the fire-and-forget focus restore before jsdom teardown.
await nextRender();
document.body.replaceChildren();
vi.clearAllMocks();
});
+89 -24
View File
@@ -14,6 +14,7 @@ import {
defineRouteSmokeTests,
ensureAppSidebarPanelVisible,
goToPanel,
openMoreInfoDialog,
} from "./app/src/helpers";
import {
expectNoPageErrors,
@@ -370,34 +371,16 @@ test.describe("Light more-info dialog", () => {
// The light-more-info scenario seeds light.test_light synchronously.
await goToPanel(page, "/?scenario=light-more-info#/lovelace");
const dialog = page.locator("ha-more-info-dialog");
// Fire the standard hass-more-info event from the app root with an
// explicit view. The HA shell opens ha-more-info-dialog on the requested
// view directly, so the test does not depend on the admin/demo-hidden
// header controls.
//
// The event is one-shot: if it lands before the shell's hass-more-info
// listener is attached it is silently dropped. Re-dispatching is
// idempotent (showDialog just resets the dialog to the requested view),
// so poll the dispatch until the requested view actually renders.
await expect(async () => {
await page.evaluate((v) => {
const el = document.querySelector("ha-test");
el?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: { entityId: "light.test_light", view: v },
bubbles: true,
composed: true,
})
);
}, view);
await expect(dialog).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(dialog.locator(element)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
}).toPass({ timeout: SHELL_TIMEOUT });
const dialog = await openMoreInfoDialog(
page,
"light.test_light",
view,
element
);
// Each view should render its own characteristic content, not just an
// empty shell.
@@ -464,6 +447,88 @@ test.describe("Weather more-info deep link", () => {
});
});
test.describe("More-info dialog URL cleanup", () => {
test("strips the deep-link params on a plain close", async ({ page }) => {
const errors = trackPageErrors(page);
// An exact route so no default-page redirect races the dialog open.
await goToPanel(page, "/?scenario=light-more-info#/config/dashboard");
const dialog = await openMoreInfoDialog(page, "light.test_light");
await expect(page).toHaveURL(/more-info-entity-id=light\.test_light/);
await dialog.getByRole("button", { name: "Close" }).click();
// The dialog re-renders empty once its close cleanup has run.
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(0, {
timeout: QUICK_TIMEOUT,
});
await expect(page).not.toHaveURL(/more-info-entity-id/);
await expect(page).toHaveURL(/#\/config\/dashboard/);
expectNoPageErrors(errors);
});
test.describe("when navigation closes the dialog", () => {
// --ha-dialog-hide-duration only applies in dialog mode; the bottom sheet
// hardcodes its animation duration, so pin a desktop viewport on every
// project to keep the slow-close setup below effective.
test.use({ viewport: { width: 1280, height: 800 } });
test("keeps the new URL when navigation outpaces the close transition", async ({
page,
}) => {
const errors = trackPageErrors(page);
// An exact route so no default-page redirect races the dialog open.
await goToPanel(page, "/?scenario=light-more-info#/config/dashboard");
const dialog = await openMoreInfoDialog(page, "light.test_light");
await expect(page).toHaveURL(/more-info-entity-id=light\.test_light/);
// Make the hide transition outlast navigate()'s dialog-close wait so
// the navigation commits its URL while the dialog is still closing,
// like on a slow device or with a long themed animation.
await page.evaluate(() => {
document.documentElement.style.setProperty(
"--ha-dialog-hide-duration",
"1200ms"
);
});
// Navigate through a synthetic same-origin link: the dialog scrim
// blocks real link clicks and the dialog's own edit/device actions are
// hidden in the demo build, while navigate() closes open dialogs the
// same way for all of them.
await page.evaluate(() => {
const anchor = document.createElement("a");
anchor.href = "/history";
document.body.append(anchor);
anchor.click();
anchor.remove();
});
await expect(page).toHaveURL(/#\/history/, { timeout: QUICK_TIMEOUT });
// The navigation must commit while the dialog is still closing,
// otherwise this test no longer covers the regression.
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(1);
// The dialog re-renders empty once its close cleanup has run.
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(0, {
timeout: QUICK_TIMEOUT,
});
// The cleanup must not rewrite the URL back to the pre-dialog page.
await expect(page).toHaveURL(/#\/history/);
await expect(page).not.toHaveURL(/more-info-entity-id/);
expectNoPageErrors(errors);
});
});
});
// ---------------------------------------------------------------------------
// Theming
// ---------------------------------------------------------------------------
+1
View File
@@ -187,6 +187,7 @@ export class HaTest extends HomeAssistantAppEl {
disabled_by: null,
domain: entry.domain,
entry_id: entry.entry_id,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
num_subentries: 0,
+32
View File
@@ -27,6 +27,38 @@ export async function goToPanel(page: Page, path: string) {
]);
}
// The hass-more-info event is one-shot: if it lands before the shell's
// listener is attached it is silently dropped. Re-dispatching is idempotent
// (showDialog just resets the dialog to the requested view), so poll the
// dispatch until the requested view actually renders.
export async function openMoreInfoDialog(
page: Page,
entityId: string,
view?: string,
readyElement = "ha-more-info-info"
) {
const dialog = page.locator("ha-more-info-dialog");
await expect(async () => {
await page.evaluate(
(detail) => {
document.querySelector("ha-test")?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail,
bubbles: true,
composed: true,
})
);
},
view ? { entityId, view } : { entityId }
);
await expect(dialog).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(dialog.locator(readyElement)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
}).toPass({ timeout: SHELL_TIMEOUT });
return dialog;
}
export const appMain = (page: Page) => page.locator(APP_MAIN_SELECTOR);
export const appSidebar = (page: Page) => page.locator(APP_SIDEBAR_SELECTOR);
+197
View File
@@ -120,6 +120,50 @@ class RaceRouter extends HassRouterPage {
}
}
class PathPanel extends HTMLElement {
public itemId = "";
}
class DashPanel extends HTMLElement {}
class PathRouter extends HassRouterPage {
public editLoads = 0;
protected routerOptions: RouterOptions = {
routes: {
dashboard: { tag: "test-dash-panel", cache: true },
edit: {
tag: "test-path-panel",
cache: true,
itemId: true,
load: () => {
this.editLoads += 1;
return Promise.resolve();
},
},
view: { tag: "test-path-panel", cache: true },
},
};
protected updatePageEl(el: PathPanel) {
if (this._currentPage === "edit" || this._currentPage === "view") {
el.itemId = this.routeTail.path.slice(1);
}
}
}
class ParentRouter extends HassRouterPage {
protected routerOptions: RouterOptions = {
routes: {
automation: { tag: "test-path-router" },
},
};
protected updatePageEl(el: PathRouter) {
el.route = this.routeTail;
}
}
customElements.define("test-router", TestRouter);
customElements.define("test-immediate-panel", ImmediatePanel);
customElements.define("test-deferred-panel", DeferredPanel);
@@ -129,6 +173,10 @@ customElements.define("test-swap-router", SwapRouter);
customElements.define("test-swap-first-panel", SwapFirstPanel);
customElements.define("test-swap-second-panel", SwapSecondPanel);
customElements.define("test-race-router", RaceRouter);
customElements.define("test-path-panel", PathPanel);
customElements.define("test-dash-panel", DashPanel);
customElements.define("test-path-router", PathRouter);
customElements.define("test-parent-router", ParentRouter);
let router: TestRouter | undefined;
@@ -294,6 +342,151 @@ describe("HassRouterPage update propagation during load", () => {
});
});
describe("HassRouterPage item ids", () => {
it("recreates the page when the item id changes", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
expect(first.itemId).toBe("a");
expect(element.editLoads).toBe(1);
element.route = { prefix: "/config/automation", path: "/edit/b" };
await element.updateComplete;
const second = element.lastElementChild as PathPanel;
expect(second).not.toBe(first);
expect(second.itemId).toBe("b");
expect(first.isConnected).toBe(false);
expect(element.editLoads).toBe(2);
element.remove();
});
it("does not cache a page that has an item id", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
element.route = { prefix: "/config/automation", path: "/dashboard" };
await element.updateComplete;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
const second = element.lastElementChild as PathPanel;
expect(second).not.toBe(first);
expect(second.itemId).toBe("a");
element.remove();
});
it("still caches pages that have no item id", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/dashboard" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as DashPanel;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
element.route = { prefix: "/config/automation", path: "/dashboard" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
element.remove();
});
it("keeps the same page when the item id is unchanged", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
expect(first.itemId).toBe("a");
element.remove();
});
it("reuses a page with a one-segment tail when itemId is not set", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/lovelace", path: "/view/0" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
expect(first.itemId).toBe("0");
element.route = { prefix: "/lovelace", path: "/view/1" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
expect(first.itemId).toBe("1");
element.remove();
});
it("does not recreate a nested router when opening an item from the dashboard", async () => {
const element = document.createElement(
"test-parent-router"
) as ParentRouter;
element.route = { prefix: "/config", path: "/automation/dashboard" };
document.body.append(element);
await element.updateComplete;
const nested = element.lastElementChild as PathRouter;
expect(nested).toBeInstanceOf(PathRouter);
await nested.updateComplete;
const dashboard = nested.lastElementChild as DashPanel;
expect(dashboard).toBeInstanceOf(DashPanel);
element.route = { prefix: "/config", path: "/automation/edit/a" };
await element.updateComplete;
await nested.updateComplete;
expect(element.lastElementChild).toBe(nested);
const editor = nested.lastElementChild as PathPanel;
expect(editor.itemId).toBe("a");
element.remove();
});
it("does not recreate a nested router when only its child's id changes", async () => {
const element = document.createElement(
"test-parent-router"
) as ParentRouter;
element.route = { prefix: "/config", path: "/automation/edit/a" };
document.body.append(element);
await element.updateComplete;
const nested = element.lastElementChild as PathRouter;
expect(nested).toBeInstanceOf(PathRouter);
await nested.updateComplete;
const firstLeaf = nested.lastElementChild as PathPanel;
expect(firstLeaf.itemId).toBe("a");
element.route = { prefix: "/config", path: "/automation/edit/b" };
await element.updateComplete;
await nested.updateComplete;
expect(element.lastElementChild).toBe(nested);
const secondLeaf = nested.lastElementChild as PathPanel;
expect(secondLeaf).not.toBe(firstLeaf);
expect(secondLeaf.itemId).toBe("b");
element.remove();
});
});
declare global {
interface HTMLElementTagNameMap {
"test-router": TestRouter;
@@ -306,5 +499,9 @@ declare global {
"test-swap-first-panel": SwapFirstPanel;
"test-swap-second-panel": SwapSecondPanel;
"test-race-router": RaceRouter;
"test-path-panel": PathPanel;
"test-dash-panel": DashPanel;
"test-path-router": PathRouter;
"test-parent-router": ParentRouter;
}
}
@@ -0,0 +1,53 @@
import { describe, expect, test, vi } from "vitest";
import { goBack, navigate } from "../../../../src/common/navigate";
import type * as NavigateModule from "../../../../src/common/navigate";
import "../../../../src/panels/config/automation/ha-automation-editor";
import type { HaAutomationEditor } from "../../../../src/panels/config/automation/ha-automation-editor";
import { createMockHass } from "../../../fixtures/hass";
vi.mock("../../../../src/common/navigate", async (importOriginal) => {
const actual = await importOriginal<typeof NavigateModule>();
return {
...actual,
goBack: vi.fn(),
navigate: vi.fn(),
};
});
vi.mock("../../../../src/dialogs/generic/show-dialog-box", () => ({
showAlertDialog: vi.fn(async () => undefined),
showConfirmationDialog: vi.fn(async () => true),
}));
describe("automation editor disconnected load", () => {
test("ignores a config fetch that 404s after the editor is disconnected", async () => {
const el = document.createElement(
"ha-automation-editor"
) as HaAutomationEditor;
const hass = createMockHass();
let rejectLoad!: (reason: { status_code: number }) => void;
(hass as any).callApi = vi.fn(
() =>
new Promise((_resolve, reject) => {
rejectLoad = reject;
})
);
el.hass = hass;
el.automations = [];
let connected = true;
Object.defineProperty(el, "isConnected", {
configurable: true,
get: () => connected,
});
const load = (el as any).loadConfig("missing");
connected = false;
rejectLoad({ status_code: 404 });
await load;
expect(goBack).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -1,89 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import {
supportsTimerActionsCardFeature,
TIMER_ACTIONS_BUTTON,
} from "../../../../src/panels/lovelace/card-features/hui-timer-actions-card-feature";
import type { HomeAssistant } from "../../../../src/types";
const entity = (entityId: string, state = "idle"): HassEntity =>
({
entity_id: entityId,
state,
attributes: {},
last_changed: "",
last_updated: "",
context: { id: "", parent_id: null, user_id: null },
}) as HassEntity;
const hassWith = (...entities: HassEntity[]): HomeAssistant =>
({
states: Object.fromEntries(entities.map((e) => [e.entity_id, e])),
}) as unknown as HomeAssistant;
describe("supportsTimerActionsCardFeature", () => {
it("supports a timer entity", () => {
const stateObj = entity("timer.test");
const hass = hassWith(stateObj);
expect(
supportsTimerActionsCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(true);
});
it("does not support other domains", () => {
const stateObj = entity("switch.test");
const hass = hassWith(stateObj);
expect(
supportsTimerActionsCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(false);
});
it("does not support a context with no entity_id", () => {
const hass = hassWith();
expect(supportsTimerActionsCardFeature(hass, {})).toBe(false);
});
});
describe("TIMER_ACTIONS_BUTTON", () => {
it("shows start when the timer is idle or paused", () => {
for (const state of ["idle", "paused"]) {
const button = TIMER_ACTIONS_BUTTON.start(entity("timer.test", state));
expect(button.translationKey).toBe("start");
expect(button.serviceName).toBe("start");
expect(button.disabled).toBe(false);
}
});
it("shows restart when the timer is active", () => {
const button = TIMER_ACTIONS_BUTTON.start(entity("timer.test", "active"));
expect(button.translationKey).toBe("restart");
expect(button.serviceName).toBe("start");
expect(button.disabled).toBe(false);
});
it("only enables pause when the timer is active", () => {
expect(
TIMER_ACTIONS_BUTTON.pause(entity("timer.test", "active")).disabled
).toBe(false);
expect(
TIMER_ACTIONS_BUTTON.pause(entity("timer.test", "paused")).disabled
).toBe(true);
expect(
TIMER_ACTIONS_BUTTON.pause(entity("timer.test", "idle")).disabled
).toBe(true);
});
it("disables cancel and finish when the timer is idle", () => {
for (const action of ["cancel", "finish"]) {
expect(
TIMER_ACTIONS_BUTTON[action](entity("timer.test", "idle")).disabled
).toBe(true);
expect(
TIMER_ACTIONS_BUTTON[action](entity("timer.test", "active")).disabled
).toBe(false);
expect(
TIMER_ACTIONS_BUTTON[action](entity("timer.test", "paused")).disabled
).toBe(false);
}
});
});

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