Compare commits

...
39 Commits
Author SHA1 Message Date
Bram Kragten 18f79dfc91 Bumped version to 20260826.6 2026-09-04 12:40:08 +02:00
Paul BotteinandBram Kragten 5861c7487c Only select ha-input-copy text on click when readonly (#53959) 2026-09-04 12:39:01 +02:00
WendelinandBram Kragten 9dbfa821d3 History: Fix "add targets" on empty state (#53953)
Fix history empty targets button only on closed filterbar
2026-09-04 12:38:59 +02:00
Bram KragtenandCopilot Autofix powered by AI 1821e8fd67 Check if failed import is from us, or from user, and check if chunck 404's (#53940)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:38:58 +02:00
Paul BotteinandBram Kragten c8a3aa1d9a Hide empty labels row in more-info related view (#53939) 2026-09-04 12:38:57 +02:00
6c606a4968 Fix websocket hang in picture card (#53930)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:38:56 +02:00
f59a4b4996 Clarify what uninstalling an app deletes (#53923)
* Clarify what uninstalling an app deletes

The uninstall dialog offered a single switch labeled "Also remove app
data". That named the wrong thing twice over: the switch sends
remove_config, which Supervisor applies to the app's configuration
folder, while the app's data folder is deleted on uninstall either way,
independent of the switch.

Relabel the switch after the folder it actually removes, and state in the
dialog that uninstalling permanently deletes the app's stored data, so
the unconditional part of the operation is no longer implied to be
optional.

Rename the local variable and the uninstallHassioAddon parameter to
removeConfig to match the API field they carry.

* Move the app name out of the uninstall dialog title

Dialog guidelines ask to keep user generated content out of titles, as
names can get long enough to be unreadable there. The name now carries
the sentence in the body, which already describes what is deleted.

* Scope the uninstall warning to the app's private data folder

Uninstalling deletes the app's data folder and, when the app maps it,
the public config folder. Files the app wrote through the share, media
or Home Assistant config mappings stay where they are, so promising
that all of its data goes away is wrong for those apps.

Name the folder the deletion is limited to, and say the configuration
folder is only deleted if the app uses one, matching how the Supervisor
API documents the field. Also switch the label to "delete" for
consistency with the sentence above it.

* Name what an app actually keeps in its data folder

Checked the 30 most installed apps for what they write there. It holds
databases (MariaDB, InfluxDB, Grafana, UniFi, Nginx Proxy Manager,
AdGuard), credentials and network identity (Matter fabrics, the Thread
network key, Tailscale node state, Z-Wave JS settings, Mosquitto
accounts) and internal state such as caches and SSH host keys.

"Any other files it created" reaches past all of that. ESPHome keeps
device configuration in the Home Assistant config folder, Node-RED sets
its user directory to /config, WireGuard writes its keys to /ssl, and
those survive an uninstall.

* Fix punctuation in uninstall dialog text

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:38:55 +02:00
Bram Kragten 4c08607dfe Bumped version to 20260826.4 2026-09-02 12:32:22 +02:00
f082816d9c Group the connectivity panels behind a Connectivity settings page (#53905)
* Group the connectivity panels behind a Connectivity settings page

Move the connectivity panels (Matter, Zigbee, Z-Wave, KNX, MQTT, Thread,
Bluetooth, serial, infrared, radio frequency, Insteon, and tags) behind a new
Connectivity page. It sits with Voice assistants in the second settings group,
where those panels used to be listed individually.

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

* Update config panel e2e coverage for the Connectivity page

The connectivity panels moved off the settings root, so split the link smoke
cases and assert them on the Connectivity page instead. Add a route smoke case
for /config/connectivity.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-02 12:30:56 +02:00
Bram Kragten b503862853 Bumped version to 20260826.3 2026-09-02 10:50:44 +02:00
Bram Kragten 1afa83d244 Fix stale trigger/condition lists (#53932) 2026-09-02 10:50:23 +02:00
Petar PetrovandBram Kragten 7b93dde3c6 Only pan sankey charts on touch devices once zoomed (#53931) 2026-09-02 10:50:22 +02:00
Paul BotteinandBram Kragten f8cb27b7ab Migrate add-to action list to grouped list style (#53921) 2026-09-02 10:50:21 +02:00
Paul BotteinandBram Kragten 62f1509be4 Split more-info related and details views (#53920)
* Split more-info related and details views

* Update the related view e2e smoke case
2026-09-02 10:50:20 +02:00
Bram Kragten efcf93191f Bumped version to 20260826.2 2026-09-01 10:34:13 +02:00
Maarten LakerveldandBram Kragten 577ededaf4 Fix log entry details collapsing to an ellipsis on narrow screens (#53881)
The secondary line of system log entries was made inline-block in #53575
to apply text direction. An inline-block is an atomic inline, so the
parent's text-overflow: ellipsis hid the whole line whenever it did not
fit, leaving only "…" on mobile. Use unicode-bidi: isolate instead so
direction still applies while the text truncates normally.

Fixes #53879
2026-09-01 10:33:46 +02:00
Paul BotteinandBram Kragten 1cd16ad301 Always allow saving vacuum segment mapping (#53862) 2026-09-01 10:33:44 +02:00
Paul BotteinandBram Kragten 2c2f733a82 Remove vacuum battery_level attribute unit (#53855) 2026-09-01 10:33:43 +02:00
Krisjanis LejejsandBram Kragten 22df9c704c Use the cloud auto-login flow in the voice satellite wizard (#53854)
Replace the wizard's own registration and sign-in screens with the cloud
panel's, extracted into cloud-register-card, so signing up mid-wizard gets
email-confirmation auto login instead of the legacy register plus login
polling. The wizard's copy of the login error ladder goes with it.

Drive the registration state from cloud/status rather than from the cloud
events, which carry nothing it does not already hold. The waiting view now
clears when the pending registration does, so a restart no longer leaves it
spinning on a registration the backend has forgotten.

Depends on the auto-login state fix in home-assistant/core#180423: without
it a retry loop that gives up before the register view stores its controller
leaves cloud/status reporting a pending registration with no reason, which
this no longer reads from the event instead.
2026-09-01 10:33:42 +02:00
Maarten LakerveldandBram Kragten d73341b2f8 Allow negative number entry on iOS when the number selector has no min (#53852)
The iOS workaround from #52925 only applied when the selector had an
explicit negative min. The numeric threshold selector used by the power
triggers passes a number selector without a min, so the digit-only keypad
(without a minus key) was still shown. Treat a missing min as allowing
negatives too.

Also leave inputmode unset instead of forcing "text": on a number input
iOS then shows the Numbers and Punctuation keyboard, which has a minus
key and fits numeric entry better than the full QWERTY keyboard.

Fixes #53747
2026-09-01 10:33:41 +02:00
Bram Kragten 907d15e7c1 Load the base map through core's tile proxy (#53845)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
(cherry picked from commit a9d7712fb2)
2026-09-01 10:32:08 +02:00
Maarten LakerveldandBram Kragten 662ac1dc57 Fix RTL map labels and add English names to non-Latin ones (#53856)
* Load MapLibre RTL text plugin so Hebrew and Arabic map labels read correctly

The vector base map never registered MapLibre's RTL text plugin, so
right-to-left scripts were shaped left to right and every label came out
reversed. Ship @mapbox/mapbox-gl-rtl-text from /static/map/ alongside the
glyphs (no third-party host) and register it once, lazily, before the
first vector layer is created.

Fixes #53851

* Add the English name to map labels in non-Latin scripts

Before the move to vector tiles the CARTO basemap showed English names
everywhere. The OSM style shows local names, which most users cannot
read in Cyrillic, Arabic, Hebrew or CJK regions. Keep the local name and
add the English one from the tiles under it - in parentheses for street
labels, which cannot break lines. Latin-script names are left alone, so
Köln stays Köln.

(cherry picked from commit 156840b6ff)
2026-09-01 10:32:02 +02:00
Paul BotteinandBram Kragten 39e7a05258 Fix the History timeline labels and tooltip placement (#53843)
* Draw History timeline names in a label column again

* Keep the timeline tooltip level with the hovered row
2026-09-01 10:27:21 +02:00
Paul BotteinandBram Kragten 5cb7de5ac6 Fix weather forecast tabs in the more info dialog (#53841) 2026-09-01 10:27:20 +02:00
Paulus SchoutsenandBram Kragten 0c7d3c8483 Render camera favorites as camera tiles (#53814) 2026-09-01 10:27:18 +02:00
lalexdotcomandBram Kragten c92e5aa134 Report the fallback camera preview as loaded in ha-camera-stream (#53810)
`ha-camera-stream` declares a `load` event and both of its video players fire it through `fireEvent(this, "load")`, but the MJPEG and snapshot fallback `<img>` never does. Every camera without a working stream therefore goes through the one path that stays silent.

`hui-image` listens for that event to record `_lastImageHeight`, and that value is what drops the `.ratio` class so the container can size to its content. Without it the container keeps the 16:9 padding box it falls back to before anything is measured, while the picture is drawn at its own ratio and overflows. Measured against a 600x410 camera in a 600px wide card: the container stays 337.5px tall while the picture is drawn 410px tall, and it is still so ten seconds after the card is built.

Firing the event from the fallback image settles it: the container becomes 410px, matching the picture, and the 16:9 fallback goes back to being a placeholder for the first frame instead of a permanent state. Cameras that do have a stream are unaffected — they already fire `load` from their player.
2026-09-01 10:27:17 +02:00
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
112 changed files with 6250 additions and 2316 deletions
+1
View File
@@ -74,3 +74,4 @@ test/e2e/app/dist/
.serena
test/benchmarks/results/
+15 -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,14 @@ 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/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
}
function copyZXingWasm(staticDir) {
@@ -139,7 +148,7 @@ gulp.task("copy-static-app", async () => {
copyMdiIcons(staticDir);
// Panel assets
copyMapPanel(staticDir);
await copyMapPanel(staticDir);
// Qr Scanner assets
copyZXingWasm(staticDir);
@@ -155,7 +164,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 +177,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 +193,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 +224,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";
+86
View File
@@ -0,0 +1,86 @@
// Generates the MapLibre styles for the vector base map.
//
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
// is what lets them be requested with an application User-Agent and without a
// referrer. The styles stay here because they come from @versatiles/style and
// core has no node toolchain to regenerate them with.
import { 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 paths from "../paths.cjs";
import { addLatinLabels } from "./map-labels.js";
const PROXY_PATH = "/api/map_tiles";
const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`;
const outputDir = path.resolve(paths.build_dir, "map");
// 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
// the proxy 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: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${PROXY_PATH}/sprites/basics/sprites` }],
};
const buildMapAssets = async () => {
await fs.emptyDir(outputDir);
await Promise.all(
// Both themes up front: dark is a real cartography, not an inverted raster.
[
["light", colorful],
["dark", eclipse],
].map(([name, builder]) =>
writeFile(
path.join(outputDir, `${name}.json`),
JSON.stringify(addLatinLabels(useTileJson(name, builder(styleOptions))))
)
)
);
};
// 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;
+42
View File
@@ -0,0 +1,42 @@
// Adds the English name to labels whose local name is not in Latin script.
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
const NAME = ["get", "name"];
const NAME_EN = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
// Streets are line-placed and cannot break lines.
const withEnglish = (placement) =>
placement === "line"
? ["concat", NAME, " (", NAME_EN, ")"]
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
export const addLatinLabels = (style) => ({
...style,
layers: style.layers.map((layer) =>
isNameLabel(layer)
? {
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
}
: layer
),
});
+4
View File
@@ -75,6 +75,8 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@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",
@@ -115,6 +117,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 +173,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 = "20260826.0"
version = "20260826.6"
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",
+32 -21
View File
@@ -1,13 +1,27 @@
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;
token?: string;
}
): Promise<LeafletMapSetup> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
}
@@ -17,7 +31,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 +56,14 @@ 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,
initialView?.token
);
return { map, leaflet: Leaflet, baseLayer };
};
+331
View File
@@ -0,0 +1,331 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
import {
MAP_TILES_PATH,
mapTilesUrl,
refreshMapTilesToken,
subscribeMapTilesToken,
withMapTilesToken,
} from "../../data/map_tiles";
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
// The demo has no proxy to go through. Upstream serves raster to a browser that
// identifies itself with a referrer, which the demo page's `same-origin` meta
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 20;
// OSM's raster stops at 19 and the proxy refuses higher, so Leaflet scales the
// last level up rather than asking for tiles that are not there.
const RASTER_MAX_NATIVE_ZOOM = 19;
// Leaflet substitutes any option into the URL template; its types do not.
type TokenTileLayerOptions = TileLayerOptions & { token?: string };
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;
};
// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would
// mangle its {fontstack} and {range} placeholders.
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
// its glyphs and sprites, which is why those come from VersaTiles.
const DEMO_UPSTREAM = {
tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json",
assets: "https://tiles.versatiles.org/assets",
};
const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`;
style.sprite = [
{ id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` },
];
Object.values(style.sources).forEach((source) => {
if ("url" in source) {
source.url = DEMO_UPSTREAM.tilejson;
}
});
return style;
};
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
return useDemoUpstream(style);
}
if (typeof style.sprite === "string") {
style.sprite = mapTilesUrl(style.sprite);
} else if (Array.isArray(style.sprite)) {
style.sprite = style.sprite.map((sprite) => ({
...sprite,
url: mapTilesUrl(sprite.url),
}));
}
return style;
};
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
rtlTextPluginRequested = true;
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
() => {
// RTL labels stay reversed; everything else still renders.
}
);
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
): Promise<MapBaseLayer | undefined> => {
let layer: ReturnType<typeof maplibreGL> | undefined;
try {
layer = createLayer({
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
// Absolute, or the worker fetching tiles cannot resolve them.
transformRequest: (url) => ({
url: withMapTilesToken(url),
// OSM asks a website for a referrer, and the demo has no instance
// hostname to leak.
referrerPolicy: __DEMO__ ? "origin" : undefined,
}),
});
// The plugin builds the MapLibre map in `onAdd`, so a refused context or a
// blocked worker throws here. Keep it guarded or those lose the fallback.
layer.addTo(map);
} catch {
if (layer) {
try {
layer.remove();
} catch {
// May never have finished being added.
}
}
return undefined;
}
// Tracked apart so a failed request rolls back to what is displayed, not to
// the opposite of what it asked - which with several in flight differs.
let appliedDarkMode = darkMode;
let requestedDarkMode = darkMode;
// Styles are fetched, so only the newest request may touch the map.
let latestRequest = 0;
let vector = true;
let refused = false;
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, token);
};
const scheduleSwap = () => {
clearTimeout(fallbackTimeout);
// Backgrounding drops it too, and there it comes back on return.
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);
});
const applyStyle = (newDarkMode: boolean) => {
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;
}
});
};
// A refused request leaves the source dead: the TileJSON is fetched once and
// is never retried, so the style has to be applied again once there is a new
// token. Throttled, or a proxy refusing for another reason loops.
let lastRecovery = 0;
glMap.on("error", (event) => {
const status = (event.error as { status?: number } | undefined)?.status;
// 403 is a stale token, 404 the proxy not registered yet during a restart,
// and no status at all a network failure. All three recover the same way,
// and a token that comes back unchanged costs nothing.
if (status !== undefined && status !== 403 && status !== 404) {
return;
}
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
return;
}
lastRecovery = Date.now();
refused = true;
refreshMapTilesToken();
});
// Only a new token clears the refusal. A theme change in between applies a
// style that is refused just as the last one was, so it proves nothing.
const unsubscribeToken = subscribeMapTilesToken(() => {
if (vector && refused) {
refused = false;
applyStyle(requestedDarkMode);
}
});
map.on("unload", unsubscribeToken);
return {
setDarkMode: (newDarkMode: boolean) => {
if (!vector || newDarkMode === requestedDarkMode) {
return;
}
requestedDarkMode = newDarkMode;
applyStyle(newDarkMode);
},
};
};
const createRasterLayer = (
leaflet: LeafletModuleType,
map: LeafletMap,
token: string | undefined
): MapBaseLayer => {
const layer = leaflet
.tileLayer(__DEMO__ ? DEMO_RASTER_TILE_URL : mapTilesUrl(RASTER_TILE_URL), {
attribution: OSM_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
maxNativeZoom: RASTER_MAX_NATIVE_ZOOM,
referrerPolicy: __DEMO__ ? "origin" : undefined,
// Leaflet throws on an undefined template variable, so no token means an
// empty one: the tiles 403 and the markers still draw.
token: token ?? "",
} as TokenTileLayerOptions)
.addTo(map);
// Substituted per request, so a refreshed token needs no new layer.
const unsubscribe = subscribeMapTilesToken((newToken) => {
(layer.options as TokenTileLayerOptions).token = newToken;
// Tiles that 403'd are cached as failures; only a redraw asks again.
layer.redraw();
});
map.on("unload", unsubscribe);
return { setDarkMode: () => undefined };
};
export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
import("@maplibre/maplibre-gl-leaflet"),
import("maplibre-gl"),
]);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
map,
darkMode,
token
);
} catch {
// No chunk, no vector map - but still a map.
}
if (vectorLayer) {
return vectorLayer;
}
}
return createRasterLayer(leaflet, map, token);
};
+1 -10
View File
@@ -10,18 +10,15 @@ const VIEW_PARAM = "more-info-view";
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
hash: URLSearchParams;
}
export interface CreateMoreInfoUrlData {
entityId: string;
view: MoreInfoView;
hash?: URLSearchParams;
}
export const decodeMoreInfoUrl = (
search: SearchParamsSource,
hash = ""
search: SearchParamsSource
): MoreInfoUrlData => {
const params =
typeof search === "string"
@@ -35,9 +32,6 @@ export const decodeMoreInfoUrl = (
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
@@ -48,9 +42,6 @@ export const createMoreInfoUrl = (
const url = new URL(base, window.location.origin);
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
url.searchParams.set(VIEW_PARAM, data.view);
if (!__DEMO__ && data.hash !== undefined) {
url.hash = data.hash.toString();
}
return `${url.pathname}${url.search}${url.hash}`;
};
+43 -14
View File
@@ -3,6 +3,27 @@ import type { TooltipPositionCallback } from "echarts/types/dist/shared";
export const TOOLTIP_GAP_PX = 12;
export const TOOLTIP_TOP_OFFSET_PX = 10;
const offsetFromCursor = (
cursorX: number,
dom: unknown,
viewW: number,
tipW: number
) => {
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
return Math.max(0, Math.min(x, viewW - tipW));
};
/**
* Pins the tooltip near the top of the chart and offsets it horizontally
* from the cursor so it never covers the data point being inspected.
@@ -20,21 +41,29 @@ export const sideTooltipPosition: TooltipPositionCallback = (
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
x = Math.max(0, Math.min(x, viewW - tipW));
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
return [x, y];
};
/**
* Offsets the tooltip horizontally from the cursor and keeps it level with it.
* For item-trigger tooltips where the cursor's row is what the tooltip shows.
*/
export const itemTooltipPosition: TooltipPositionCallback = (
point,
_params,
dom,
_rect,
size
) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const y = Math.max(0, Math.min(cursorY - tipH / 2, viewH - tipH));
return [x, y];
};
+20 -3
View File
@@ -362,7 +362,7 @@ export class HaChartBase extends LitElement {
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series) {
if (chartOptions.series || changedProps.has("_isZoomed")) {
this._updateSankeyRoam();
}
}
@@ -654,6 +654,11 @@ export class HaChartBase extends LitElement {
echarts.registerTheme("custom", this._createTheme(style));
this.chart = echarts.init(this._chartContainer!, "custom");
if (this._isZoomed) {
this._isZoomed = false;
this._zoomRatio = 1;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
this.chart.on("datazoom", (e: any) => {
this._handleDataZoomEvent(e);
});
@@ -664,7 +669,7 @@ export class HaChartBase extends LitElement {
const option = this.chart!.getOption();
const series = option.series as any[];
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = sankeySeries.zoom !== 1;
const zoomed = Math.abs(sankeySeries.zoom - 1) > 1e-6;
this._isZoomed = zoomed;
if (!zoomed) {
// Reset center when fully zoomed out
@@ -1279,12 +1284,24 @@ export class HaChartBase extends LitElement {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
roam: this._modifierPressed || this._isTouchDevice ? true : "move",
roam: this._getSankeyRoam(),
})),
});
}
}
// On touch devices a drag pans only once zoomed, so an unzoomed chart
// does not swallow page scrolling. Pinch still zooms via "scale".
private _getSankeyRoam(): boolean | "move" | "scale" {
if (this._modifierPressed) {
return true;
}
if (this._isTouchDevice) {
return this._isZoomed ? true : "scale";
}
return "move";
}
private _handleDataZoomEvent(e: any) {
const zoomData = e.batch?.[0] ?? e;
let start = typeof zoomData.start === "number" ? zoomData.start : 0;
@@ -1,4 +1,3 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -13,7 +12,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -24,7 +23,6 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
@@ -43,10 +41,6 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -69,13 +63,6 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -83,7 +70,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -193,19 +180,13 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth") ||
widthChanged
changedProps.has("_yWidth")
) {
this._createOptions();
}
@@ -215,22 +196,14 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -254,56 +227,41 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: insideLabels
? {
show: showNames,
inside: true,
margin: 0,
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
return label;
},
hideOverlap: true,
},
},
grid: {
top: insideLabels ? 20 : 10,
top: 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
},
tooltip: {
renderMode: "html",
position: sideTooltipPosition,
position: itemTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -443,10 +401,6 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,10 +79,6 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw timeline row names above their bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -231,7 +227,6 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -429,12 +424,6 @@ export class StateHistoryCharts extends LitElement {
padding-top: 8px;
}
/* Names inside the plot sit close to the chart above them, so the groups
need more room between them to stay apart. */
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.entry-container:hover {
z-index: 1;
}
@@ -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;
+6
View File
@@ -5,6 +5,7 @@ import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import { STATE_RUNNING } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateName } from "../common/entity/compute_state_name";
import { supportsFeature } from "../common/entity/supports-feature";
import {
@@ -149,6 +150,7 @@ export class HaCameraStream extends LitElement {
objectFit: this.fitMode,
})}
alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
@load=${this._handleImageLoad}
/>`;
}
@@ -214,6 +216,10 @@ export class HaCameraStream extends LitElement {
}
}
private _handleImageLoad() {
fireEvent(this, "load");
}
private _handleHlsStreams(ev: CustomEvent) {
this._hlsStreams = ev.detail;
}
+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 ||
+267 -313
View File
@@ -1,16 +1,17 @@
import {
mdiAlertCircleOutline,
mdiDevices,
mdiPaletteSwatch,
mdiTextureBox,
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { Blueprints } from "../data/blueprint";
import { fetchBlueprints } from "../data/blueprint";
@@ -18,14 +19,15 @@ import type { ConfigEntry } from "../data/config_entries";
import { getConfigEntries } from "../data/config_entries";
import type { ItemType, RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import "./ha-icon";
import "./ha-icon-next";
import "./ha-list-item";
import "./ha-state-icon";
import "./ha-switch";
import "./ha-list";
import "./ha-svg-icon";
import "./item/ha-list-item-button";
import type { HaListItemButton } from "./item/ha-list-item-button";
import "./list/ha-grouped-list";
@customElement("ha-related-items")
export class HaRelatedItems extends LitElement {
@@ -35,16 +37,16 @@ export class HaRelatedItems extends LitElement {
@property({ attribute: false }) public itemId!: string;
@property({ attribute: false }) public exclude?: (keyof RelatedResult)[];
@property({ type: Boolean, reflect: true }) public empty = true;
@state() private _entries?: ConfigEntry[];
@state() private _blueprints?: Record<"automation" | "script", Blueprints>;
@state() private _related?: RelatedResult;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
}
private async _fetchConfigEntries() {
if (this._entries) {
return;
@@ -64,6 +66,19 @@ export class HaRelatedItems extends LitElement {
this._blueprints = { automation, script };
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_related") || changedProps.has("exclude")) {
this.empty =
!this._related ||
!Object.entries(this._related).some(
([section, items]) =>
items?.length &&
!this.exclude?.includes(section as keyof RelatedResult)
);
}
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (
@@ -127,25 +142,14 @@ export class HaRelatedItems extends LitElement {
}
);
private _isExcluded(section: keyof RelatedResult) {
return this.exclude?.includes(section) ?? false;
}
protected render() {
if (!this._related) {
return nothing;
}
if (Object.keys(this._related).length === 0) {
return html`
<ha-list>
<ha-list-item hasMeta graphic="icon" noninteractive>
<ha-svg-icon
.path=${mdiAlertCircleOutline}
slot="graphic"
></ha-svg-icon>
${this.hass.localize(
"ui.components.related-items.no_related_found"
)}
</ha-list-item>
</ha-list>
`;
}
const { configEntries, configEntryDomains } = this._getConfigEntries(
this._related.config_entry,
@@ -154,97 +158,77 @@ export class HaRelatedItems extends LitElement {
return html`
${
this._related.entity
this._related.entity && !this._isExcluded("entity")
? html`
<h3>
${this.hass.localize("ui.components.related-items.entity")}
</h3>
<ha-list>
${this._relatedEntities(this._related.entity).map(
(entity) => html`
<ha-list-item
@click=${this._openMoreInfo}
.entityId=${entity.entity_id}
hasMeta
graphic="icon"
>
<ha-state-icon
.stateObj=${entity}
slot="graphic"
></ha-state-icon>
${entity.attributes.friendly_name || entity.entity_id}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.related-items.entity"
)}
</ha-list>
>
${this._relatedEntities(this._related.entity).map((entity) =>
this._renderEntityRow(entity)
)}
</ha-grouped-list>
`
: nothing
}
${
this._related.device
? html`<h3>
${this.hass.localize("ui.components.related-items.device")}
</h3>
<ha-list>
this._related.device && !this._isExcluded("device")
? html`
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.related-items.device"
)}
>
${this._related.device.map((relatedDeviceId) => {
const device = this.hass.devices[relatedDeviceId];
if (!device) {
return nothing;
}
return html`
<a href="/config/devices/device/${relatedDeviceId}">
<ha-list-item hasMeta graphic="icon">
<ha-svg-icon
.path=${
device.entry_type === "service"
? mdiTransitConnectionVariant
: mdiDevices
}
slot="graphic"
></ha-svg-icon>
${device.name_by_user || device.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
<ha-list-item-button
.href=${`/config/devices/device/${relatedDeviceId}`}
.headline=${device.name_by_user || device.name || ""}
>
<ha-svg-icon
slot="start"
.path=${
device.entry_type === "service"
? mdiTransitConnectionVariant
: mdiDevices
}
></ha-svg-icon>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
})}
</ha-list>`
</ha-grouped-list>
`
: nothing
}
${
configEntries || this._related.integration
? html`<h3>
${this.hass.localize("ui.components.related-items.integration")}
</h3>
<ha-list
>${configEntries?.map((entry) => {
(configEntries || this._related.integration) &&
!this._isExcluded("integration")
? html`
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.related-items.integration"
)}
>
${configEntries?.map((entry) => {
if (!entry) {
return nothing;
}
return html`
<a
href=${`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`}
<ha-list-item-button
.href=${`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`}
.headline=${`${this.hass.localize(
`component.${entry.domain}.title`
)}: ${entry.title}`}
>
<ha-list-item hasMeta graphic="icon">
<img
.src=${brandsUrl(
{
domain: entry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${entry.domain}
slot="graphic"
/>
${this.hass.localize(`component.${entry.domain}.title`)}:
${entry.title} <ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
${this._renderBrandIcon(entry.domain)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
})}
${this._related.integration
@@ -252,259 +236,216 @@ export class HaRelatedItems extends LitElement {
(integration) => !configEntryDomains.has(integration)
)
.map(
(integration) =>
html`<a
href=${`/config/integrations/integration/${integration}`}
(integration) => html`
<ha-list-item-button
.href=${`/config/integrations/integration/${integration}`}
.headline=${this.hass.localize(
`component.${integration}.title`
)}
>
<ha-list-item hasMeta graphic="icon">
<img
.src=${brandsUrl(
{
domain: integration,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${integration}
slot="graphic"
/>
${this.hass.localize(`component.${integration}.title`)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
${this._renderBrandIcon(integration)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>`
</ha-grouped-list>
`
: nothing
}
${
this._related.area
? html`<h3>
${this.hass.localize("ui.components.related-items.area")}
</h3>
<ha-list
>${this._related.area.map((relatedAreaId) => {
this._related.area && !this._isExcluded("area")
? html`
<ha-grouped-list
.header=${this.hass.localize("ui.components.related-items.area")}
>
${this._related.area.map((relatedAreaId) => {
const area = this.hass.areas[relatedAreaId];
if (!area) {
return nothing;
}
return html`
<a href="/config/areas/area/${relatedAreaId}">
<ha-list-item
hasMeta
.graphic=${area.picture ? "avatar" : "icon"}
>
${
area.picture
? html` <div
<ha-list-item-button
.href=${`/config/areas/area/${relatedAreaId}`}
.headline=${area.name}
>
${
area.picture
? html`
<div
class="avatar"
style=${styleMap({
backgroundImage: `url(${area.picture})`,
})}
slot="graphic"
></div>`
: area.icon
? html`<ha-icon
slot="graphic"
slot="start"
></div>
`
: area.icon
? html`
<ha-icon
slot="start"
.icon=${area.icon}
></ha-icon>`
: html`<ha-svg-icon
slot="graphic"
></ha-icon>
`
: html`
<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`
}
${area.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
></ha-svg-icon>
`
}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
})}
</ha-list>`
</ha-grouped-list>
`
: nothing
}
${
this._related.group
this._related.group && !this._isExcluded("group")
? html`
<h3>
${this.hass.localize("ui.components.related-items.group")}
</h3>
<ha-list>
${this._relatedGroups(this._related.group).map(
(group) => html`
<ha-list-item
@click=${this._openMoreInfo}
.entityId=${group.entity_id}
hasMeta
graphic="icon"
>
<ha-state-icon
.stateObj=${group}
slot="graphic"
></ha-state-icon>
${group.attributes.friendly_name || group.entity_id}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
<ha-grouped-list
.header=${this.hass.localize("ui.components.related-items.group")}
>
${this._relatedGroups(this._related.group).map((group) =>
this._renderEntityRow(group)
)}
</ha-list>
</ha-grouped-list>
`
: nothing
}
${
this._related.scene
this._related.scene && !this._isExcluded("scene")
? html`
<h3>
${this.hass.localize("ui.components.related-items.scene")}
</h3>
<ha-list>
${this._relatedScenes(this._related.scene).map(
(scene) => html`
<ha-list-item
@click=${this._openMoreInfo}
.entityId=${scene.entity_id}
hasMeta
graphic="icon"
>
<ha-state-icon
.stateObj=${scene}
slot="graphic"
></ha-state-icon>
${scene.attributes.friendly_name || scene.entity_id}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
<ha-grouped-list
.header=${this.hass.localize("ui.components.related-items.scene")}
>
${this._relatedScenes(this._related.scene).map((scene) =>
this._renderEntityRow(scene)
)}
</ha-list>
</ha-grouped-list>
`
: nothing
}
${
this._related.automation_blueprint
this._related.automation_blueprint &&
!this._isExcluded("automation_blueprint")
? html`
<h3>
${this.hass.localize("ui.components.related-items.blueprint")}
</h3>
<ha-list>
${this._related.automation_blueprint.map((path) => {
const blueprintMeta = this._blueprints
? this._blueprints.automation[path]
: undefined;
return html`<a href="/config/blueprint/dashboard">
<ha-list-item hasMeta graphic="icon">
<ha-svg-icon
.path=${mdiPaletteSwatch}
slot="graphic"
></ha-svg-icon>
${
!blueprintMeta || "error" in blueprintMeta
? path
: blueprintMeta.metadata.name || path
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`;
})}
</ha-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.related-items.blueprint"
)}
>
${this._related.automation_blueprint.map((path) =>
this._renderBlueprintRow(path, "automation")
)}
</ha-grouped-list>
`
: nothing
}
${
this._related.automation
this._related.automation && !this._isExcluded("automation")
? html`
<h3>
${this.hass.localize("ui.components.related-items.automation")}
</h3>
<ha-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.related-items.automation"
)}
>
${this._relatedAutomations(this._related.automation).map(
(automation) => html`
<ha-list-item
@click=${this._openMoreInfo}
.entityId=${automation.entity_id}
hasMeta
graphic="icon"
>
<ha-state-icon
.stateObj=${automation}
slot="graphic"
></ha-state-icon>
${
automation.attributes.friendly_name ||
automation.entity_id
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
(automation) => this._renderEntityRow(automation)
)}
</ha-list>
</ha-grouped-list>
`
: nothing
}
${
this._related.script_blueprint
this._related.script_blueprint && !this._isExcluded("script_blueprint")
? html`
<h3>
${this.hass.localize("ui.components.related-items.blueprint")}
</h3>
<ha-list>
${this._related.script_blueprint.map((path) => {
const blueprintMeta = this._blueprints
? this._blueprints.script[path]
: undefined;
return html`<a href="/config/blueprint/dashboard">
<ha-list-item hasMeta graphic="icon">
<ha-svg-icon
.path=${mdiPaletteSwatch}
slot="graphic"
></ha-svg-icon>
${
!blueprintMeta || "error" in blueprintMeta
? path
: blueprintMeta.metadata.name || path
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`;
})}
</ha-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.related-items.blueprint"
)}
>
${this._related.script_blueprint.map((path) =>
this._renderBlueprintRow(path, "script")
)}
</ha-grouped-list>
`
: nothing
}
${
this._related.script
this._related.script && !this._isExcluded("script")
? html`
<h3>
${this.hass.localize("ui.components.related-items.script")}
</h3>
<ha-list>
${this._relatedScripts(this._related.script).map(
(script) => html`
<ha-list-item
@click=${this._openMoreInfo}
.entityId=${script.entity_id}
hasMeta
graphic="icon"
>
<ha-state-icon
.stateObj=${script}
slot="graphic"
></ha-state-icon>
${script.attributes.friendly_name || script.entity_id}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.related-items.script"
)}
</ha-list>
>
${this._relatedScripts(this._related.script).map((script) =>
this._renderEntityRow(script)
)}
</ha-grouped-list>
`
: nothing
}
`;
}
private _renderEntityRow(entity: HassEntity) {
return html`
<ha-list-item-button
.headline=${entity.attributes.friendly_name || entity.entity_id}
data-entity-id=${entity.entity_id}
@click=${this._openMoreInfo}
>
<ha-state-icon slot="start" .stateObj=${entity}></ha-state-icon>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
}
private _renderBrandIcon(domain: string) {
return html`
<img
slot="start"
alt=""
loading="lazy"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`;
}
private _renderBlueprintRow(path: string, type: "automation" | "script") {
const blueprintMeta = this._blueprints
? this._blueprints[type][path]
: undefined;
return html`
<ha-list-item-button
href="/config/blueprint/dashboard"
.headline=${
!blueprintMeta || "error" in blueprintMeta
? path
: blueprintMeta.metadata.name || path
}
>
<ha-svg-icon slot="start" .path=${mdiPaletteSwatch}></ha-svg-icon>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
}
private async _findRelated() {
this._related = await findRelated(this.hass, this.itemType, this.itemId);
if (this._related.config_entry) {
if (this._related.config_entry && !this._isExcluded("integration")) {
this._fetchConfigEntries();
}
if (this._related.script_blueprint || this._related.automation_blueprint) {
@@ -512,36 +453,49 @@ export class HaRelatedItems extends LitElement {
}
}
private _openMoreInfo(ev: CustomEvent) {
const entityId = (ev.target as any).entityId;
private _openMoreInfo(ev: HASSDomCurrentTargetEvent<HaListItemButton>) {
const entityId = ev.currentTarget.dataset.entityId;
if (!entityId) {
return;
}
fireEvent(this, "hass-more-info", { entityId });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
a {
color: var(--primary-color);
text-decoration: none;
}
ha-list-item {
--mdc-list-side-padding: 24px;
}
h3 {
padding: 0 24px;
margin-bottom: -8px;
}
h3:first-child {
margin-top: 0;
}
.avatar {
background-position: center center;
background-size: cover;
}
`,
];
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
}
:host([empty]) {
display: none;
}
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;
}
img[slot="start"],
.avatar {
width: 20px;
height: 20px;
object-fit: contain;
}
.avatar {
border-radius: var(--ha-border-radius-circle);
background-position: center center;
background-size: cover;
}
ha-icon-next {
color: var(--secondary-text-color);
}
`;
}
declare global {
@@ -67,15 +67,16 @@ export class HaNumberSelector extends LitElement {
}
}
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
// so negatives can only be typed with the full "text" keyboard. Other
// platforms include a minus on their number keypads, so restrict this
// workaround to Safari/WebKit and only when the selector allows negatives
// (e.g. numeric_state triggers/conditions).
const useTextInputMode =
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key.
// Leaving inputmode unset on a number input gives the "Numbers and
// Punctuation" keyboard there, which does include a minus. Other platforms
// include a minus on their number keypads, so restrict this workaround to
// Safari/WebKit and only when the selector allows negatives: either an
// explicit negative min, or no min at all (e.g. the numeric threshold
// selector used by the power triggers).
const useSafariNegativeKeyboard =
isSafari &&
this.selector.number?.min !== undefined &&
this.selector.number.min < 0;
(this.selector.number?.min === undefined || this.selector.number.min < 0);
const translationKey = this.selector.number?.translation_key;
let unit = this.selector.number?.unit_of_measurement;
@@ -112,8 +113,8 @@ export class HaNumberSelector extends LitElement {
}
<ha-input
.inputmode=${
useTextInputMode
? "text"
useSafariNegativeKeyboard
? undefined
: this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
+3
View File
@@ -113,6 +113,9 @@ export class HaInputCopy extends LitElement {
}
private _focusInput(ev: HASSDomCurrentTargetEvent<HaInput>) {
if (!this.readonly) {
return;
}
const inputElement = ev.currentTarget as HaInput;
inputElement.select();
}
+41 -2
View File
@@ -25,9 +25,11 @@ 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";
import { ensureMapTilesToken } from "../../data/map_tiles";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import {
@@ -160,6 +162,8 @@ export class HaMap extends ReactiveElement {
private Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _resizeObserver?: ResizeObserver;
private _mapItems: (Marker | Circle)[] = [];
@@ -211,6 +215,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 +313,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 +328,31 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
// The tiles are proxied by core behind a token, so nothing loads without
// one. A host that provides no connection, or a backend without the
// proxy, leaves the map without tiles rather than failing to set up.
const token = this._connection
? await ensureMapTilesToken(this._connection.connection)
: undefined;
const setup = await setupLeafletMap(map, {
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
zoom: this.zoom,
darkMode: this._darkMode,
token,
});
// 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 +917,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
+5 -15
View File
@@ -64,6 +64,11 @@ export interface CloudStatusLoggedIn {
export type CloudStatus = CloudStatusNotLoggedIn | CloudStatusLoggedIn;
export const cloudStatusAutoLogin = (
status: CloudStatus | undefined
): CloudAutoLogin | null =>
!status || status.logged_in ? null : (status.auto_login ?? null);
// Onboarding items the backend tracks. Mirrors ONBOARDING_ITEMS in the cloud
// integration; onboarding is complete once every item has been onboarded.
export const ONBOARDING_ITEMS = [
@@ -126,16 +131,6 @@ export const cloudForgotPassword = (hass: HomeAssistant, email: string) =>
email,
});
export const cloudRegister = (
hass: HomeAssistant,
email: string,
password: string
) =>
hass.callApi("POST", "cloud/register", {
email,
password,
});
export const cloudRegisterAutoLogin = (
hass: HomeAssistant,
email: string,
@@ -163,11 +158,6 @@ export const subscribeCloudEvents = (
type: "cloud/subscribe_events",
});
export const cloudResendVerification = (hass: HomeAssistant, email: string) =>
hass.callApi("POST", "cloud/resend_confirm", {
email,
});
export const fetchCloudStatus = (hass: HomeAssistant) =>
hass.callWS<CloudStatus>({ type: "cloud/status" });
+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 = (
-3
View File
@@ -50,9 +50,6 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
azimuth: "°",
elevation: "°",
},
vacuum: {
battery_level: "%",
},
valve: {
current_position: "%",
},
+2 -2
View File
@@ -280,14 +280,14 @@ export const restartHassioAddon = async (
export const uninstallHassioAddon = async (
callWS: CallWS,
slug: string,
removeData: boolean
removeConfig: boolean
): Promise<void> => {
await callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/uninstall`,
method: "post",
timeout: null,
data: { remove_config: removeData },
data: { remove_config: removeConfig },
});
};
+185
View File
@@ -0,0 +1,185 @@
import type { Connection } from "home-assistant-js-websocket";
import { waitForMs } from "../common/util/wait";
export const MAP_TILES_PATH = "/api/map_tiles";
// Core rotates every 30 minutes and keeps two tokens live, so one handed out
// now is good for at least 30 more. Refreshing sooner leaves room for a slow
// or missed round trip.
const TOKEN_REFRESH_MS = 20 * 60 * 1000;
// Nothing loads without a token, so the first attempts are awaited - briefly,
// or a backend without the proxy would hold the map hostage. The rest retry in
// the background, for the window after a restart where the WebSocket is up but
// the handler is not registered yet.
const BLOCKING_DELAYS_MS = [0, 400, 1000];
const BACKGROUND_DELAYS_MS = [2000, 5000, 10000, 15000];
let token: string | undefined;
let instanceUrl: string | undefined;
let acquiring: Promise<void> | undefined;
let background: Promise<void> | undefined;
let refreshInterval: ReturnType<typeof setInterval> | undefined;
let watchedConnection: Connection | undefined;
let activeConnection: Connection | undefined;
let refreshing: Promise<void> | undefined;
const listeners = new Set<(token: string) => void>();
const fetchToken = async (connection: Connection): Promise<void> => {
const result = await connection.sendMessagePromise<{ token: string }>({
type: "map_tiles/access_token",
});
if (result.token !== token) {
token = result.token;
listeners.forEach((listener) => listener(token!));
}
};
const attempt = async (connection: Connection, delays: number[]) => {
/* eslint-disable no-await-in-loop -- retries are intentionally sequential */
for (const delay of delays) {
if (token) {
return;
}
if (delay) {
await waitForMs(delay);
}
try {
await fetchToken(connection);
return;
} catch {
// try next delay
}
}
/* eslint-enable no-await-in-loop */
};
export const ensureMapTilesToken = async (
connection: Connection
): Promise<string | undefined> => {
// The demo has no proxy to ask, and its tiles come straight from upstream.
if (__DEMO__) {
return undefined;
}
activeConnection = connection;
// Cast serves this page from its own host, so the proxy is not on this origin.
instanceUrl = connection.options.auth?.data.hassUrl;
// Shared, or a dashboard full of maps asks once per map - and a backend
// without the proxy turns that into every retry, per map.
if (!token) {
acquiring ??= attempt(connection, BLOCKING_DELAYS_MS).finally(() => {
acquiring = undefined;
});
await acquiring;
}
if (!token) {
background ??= attempt(connection, BACKGROUND_DELAYS_MS)
.then(() => scheduleRefresh(connection))
.finally(() => {
background = undefined;
});
return undefined;
}
scheduleRefresh(connection);
return token;
};
// Always the current connection: Cast builds a fresh one per connect while this
// module lives on, and refreshing over the closed one stops the rotation.
const handleReady = () => {
if (!activeConnection) {
return;
}
const connection = activeConnection;
fetchToken(connection)
// A token first acquired here would otherwise never get an interval.
.then(() => scheduleRefresh(connection))
.catch(() => {
// Nothing to do; the next reconnect tries again.
});
};
const scheduleRefresh = (connection: Connection) => {
if (token && !refreshInterval) {
refreshInterval = setInterval(() => {
fetchToken(activeConnection ?? connection).catch(() => {
// Keep the current token; the next interval retries.
});
}, TOKEN_REFRESH_MS);
}
if (watchedConnection !== connection) {
// The interval does not fire while the process is suspended, so the token
// can be stale before it comes round; reconnecting is the reliable signal.
watchedConnection?.removeEventListener("ready", handleReady);
watchedConnection = connection;
connection.addEventListener("ready", handleReady);
}
};
/**
* Forces a new token, for when the current one is refused. Deduplicated: a
* refused map produces one of these per tile.
*/
export const refreshMapTilesToken = (): Promise<void> => {
if (!activeConnection) {
return Promise.resolve();
}
refreshing ??= fetchToken(activeConnection)
.catch(() => {
// Leave the old token in place; a reconnect or the interval retries.
})
.finally(() => {
refreshing = undefined;
});
return refreshing;
};
/** Leaflet bakes its URL template at layer creation, so it needs telling. */
export const subscribeMapTilesToken = (
listener: (token: string) => void
): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const instanceOrigin = () =>
(instanceUrl ?? location.origin).replace(/\/+$/, "");
/**
* Prefixes a proxy path with the instance that serves it. Concatenated rather
* than resolved, so `{z}` and `{fontstack}` survive instead of being encoded.
*/
export const mapTilesUrl = (path: string): string =>
path.startsWith("/") ? `${instanceOrigin()}${path}` : path;
/**
* MapLibre hands tile URLs to a worker, which has no document to resolve a
* relative URL against, so the result has to be absolute.
*/
export const withMapTilesToken = (url: string): string => {
let parsed: URL;
try {
parsed = new URL(url, instanceOrigin());
} catch {
return url;
}
if (!parsed.pathname.startsWith(`${MAP_TILES_PATH}/`)) {
return parsed.href;
}
// Rebuilt against the instance: MapLibre resolves the style's paths against
// this page first, and on Cast that is not the host serving the proxy.
const onInstance = new URL(
`${instanceOrigin()}${parsed.pathname}${parsed.search}`
);
if (token) {
onInstance.searchParams.set("token", token);
}
return onInstance.href;
};
+4
View File
@@ -104,3 +104,7 @@ export const removeLocalMedia = async (
type: "media_source/local_source/remove",
media_content_id,
});
export const isStreamingMedia = (url: string): boolean =>
url.includes("/api/image_proxy_stream") ||
url.includes("/api/camera_proxy_stream");
+18 -28
View File
@@ -15,7 +15,7 @@ import "../../components/ha-icon";
import "../../components/ha-svg-icon";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
import "../../components/list/ha-grouped-list";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
export interface AddToActionListItem {
@@ -76,20 +76,19 @@ class HaAddToActionList extends LitElement {
}
return html`
<h3 class="section-header">
${this._localizeValue(section.title, section.titleKey)}
</h3>
${
section.actions.length
? html`<ha-list-base>
${section.actions.map((action, actionIndex) =>
<ha-grouped-list
.header=${this._localizeValue(section.title, section.titleKey)}
>
${
section.actions.length
? section.actions.map((action, actionIndex) =>
this._renderActionItem(action, sectionIndex, actionIndex)
)}
</ha-list-base>`
: html`<h4 class="empty">
${this._localizeValue(section.empty, section.emptyKey)}
</h4>`
}
)
: html`<div class="empty">
${this._localizeValue(section.empty, section.emptyKey)}
</div>`
}
</ha-grouped-list>
`;
}
@@ -161,28 +160,19 @@ class HaAddToActionList extends LitElement {
static styles: CSSResultGroup = css`
:host {
display: block;
padding: 0 var(--ha-space-6);
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-1);
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
ha-grouped-list + ha-grouped-list {
margin-top: var(--ha-space-6);
}
.empty {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-1);
margin: 0;
padding: var(--ha-space-2) var(--ha-space-3);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
color: var(--secondary-text-color);
}
ha-list-item-button {
--ha-row-item-padding-inline: var(--ha-space-5);
}
ha-icon,
ha-svg-icon {
display: flex;
@@ -194,7 +184,7 @@ class HaAddToActionList extends LitElement {
}
.plus {
color: var(--primary-color);
color: var(--secondary-text-color);
}
ha-list-item-button[disabled] .start-icon,
+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;
}
@@ -116,10 +116,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
></ha-vacuum-segment-area-mapper>
<div class="footer">
<ha-button
@click=${this._save}
.disabled=${!this._dirtyState?.isDirty || this._submitting}
>
<ha-button @click=${this._save} .disabled=${this._submitting}>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
-9
View File
@@ -1,9 +0,0 @@
import { createContext } from "@lit/context";
export interface MoreInfoContext {
hash: URLSearchParams;
setHashParam: (key: string, value?: string) => void;
}
export const moreInfoContext =
createContext<MoreInfoContext>("more-info-context");
@@ -33,7 +33,6 @@ import type {
WeatherEntity,
} from "../../../data/weather";
import {
getDefaultForecastType,
getForecast,
getSecondaryWeatherAttribute,
getSupportedForecastTypes,
@@ -49,16 +48,11 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { moreInfoContext, type MoreInfoContext } from "../context";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public stateObj?: WeatherEntity;
@state()
@consume({ context: moreInfoContext, subscribe: true })
private _moreInfoContext?: MoreInfoContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@@ -82,7 +76,9 @@ class MoreInfoWeather extends LitElement {
@state() private _forecastType?: ModernForecastType;
@state() private _subscribed?: Promise<() => void>;
private _subscribed?: Promise<() => void>;
private _subscribedTo?: string;
private _dragScrollController = new DragScrollController(this, {
selector: ".forecast",
@@ -94,24 +90,30 @@ class MoreInfoWeather extends LitElement {
this._subscribed.then((unsub) => unsub());
this._subscribed = undefined;
}
this._subscribedTo = undefined;
this._forecastEvent = undefined;
}
private async _subscribeForecastEvents() {
this._unsubscribeForecastEvents();
if (
!this.isConnected ||
!this._connection ||
!this.stateObj ||
!this._forecastType
) {
private _updateForecastSubscription() {
const stateObj = this.stateObj;
const forecastType = this._forecastType;
if (!this.isConnected || !this._connection || !stateObj || !forecastType) {
this._unsubscribeForecastEvents();
return;
}
const target = `${stateObj.entity_id}-${forecastType}`;
if (target === this._subscribedTo) {
return;
}
this._unsubscribeForecastEvents();
this._subscribedTo = target;
this._subscribed = subscribeForecast(
this._connection.connection,
this.stateObj.entity_id,
this._forecastType,
stateObj.entity_id,
forecastType,
(event) => {
this._forecastEvent = event;
}
@@ -121,7 +123,7 @@ class MoreInfoWeather extends LitElement {
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeForecastEvents();
this._updateForecastSubscription();
}
}
@@ -133,37 +135,9 @@ class MoreInfoWeather extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
(changedProps.has("stateObj") ||
changedProps.has("_moreInfoContext") ||
!this._subscribed) &&
this.stateObj
) {
const oldState = changedProps.get("stateObj") as
WeatherEntity | undefined;
if (
oldState?.entity_id !== this.stateObj?.entity_id ||
changedProps.has("_moreInfoContext") ||
!this._subscribed
) {
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
const requestedForecastType =
this._moreInfoContext?.hash.get("forecast");
const selectedForecastType =
supportedForecastTypes.find(
(forecastType) => forecastType === requestedForecastType
) ?? getDefaultForecastType(this.stateObj);
if (selectedForecastType !== requestedForecastType) {
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
}
if (this._forecastType !== selectedForecastType || !this._subscribed) {
this._forecastType = selectedForecastType;
this._subscribeForecastEvents();
}
}
} else if (changedProps.has("_forecastType")) {
this._subscribeForecastEvents();
}
this._forecastType = this._selectedForecastType();
this._updateForecastSubscription();
}
protected updated(_changedProps: PropertyValues<this>): void {
@@ -184,6 +158,16 @@ class MoreInfoWeather extends LitElement {
getSupportedForecastTypes(stateObj)
);
private _selectedForecastType(): ModernForecastType | undefined {
if (!this.stateObj) {
return undefined;
}
const supported = this._supportedForecasts(this.stateObj);
return (
supported.find((type) => type === this._forecastType) ?? supported[0]
);
}
private _groupForecastByDay = memoizeOne((forecast: ForecastAttribute[]) => {
if (!forecast) return [];
@@ -536,14 +520,7 @@ class MoreInfoWeather extends LitElement {
private _handleForecastTypeChanged(
ev: HASSDomEvent<{ name: ModernForecastType }>
): void {
if (
!this.stateObj ||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
) {
return;
}
this._forecastType = ev.detail.name;
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
}
static get styles(): CSSResultGroup {
+113 -141
View File
@@ -1,22 +1,21 @@
import { consume } from "@lit/context";
import { mdiCheck, mdiContentCopy } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import checkValidDate from "../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import "../../components/ha-attribute-value";
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";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { HomeAssistant } from "../../types";
import "../../components/ha-yaml-editor";
import { computeDomain } from "../../common/entity/compute_domain";
@@ -25,6 +24,7 @@ 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 { showToast } from "../../util/toast";
interface DetailsViewParams {
entityId: string;
@@ -33,7 +33,7 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
href?: string;
copyable?: boolean;
}
@customElement("ha-more-info-details")
@@ -48,15 +48,19 @@ class HaMoreInfoDetails extends LitElement {
@state() private _stateObj?: HassEntity;
@consume({ context: labelsContext, subscribe: true })
@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) {
this.hass.loadBackendTranslation("title", [this.entry.platform]);
}
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
@@ -77,86 +81,17 @@ class HaMoreInfoDetails extends LitElement {
this._stateObj,
this.hass.formatEntityAttributeName
);
const { floor, area, device } = getEntityContext(
this._stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
const deviceName = device
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
: undefined;
const integrationName = this.entry?.platform
? 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 contextEntries: DetailEntry[] = [];
if (floor && floorName) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.floor",
value: floorName,
href: "/config/areas/dashboard",
});
}
if (area && areaName) {
contextEntries.push({
translationKey: "ui.components.related-items.area",
value: areaName,
href: `/config/areas/area/${area.area_id}`,
});
}
if (device && deviceName) {
contextEntries.push({
translationKey: "ui.components.related-items.device",
value: deviceName,
href: `/config/devices/device/${device.id}`,
});
}
if (this.entry?.platform && integrationName) {
contextEntries.push({
translationKey: "ui.components.related-items.integration",
value: integrationName,
href: this.entry.config_entry_id
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
: undefined,
});
}
const entityEntries: DetailEntry[] = [
{
translationKey: "ui.dialogs.more_info_control.entity_id",
value: this.params.entityId,
},
{
translationKey: "ui.dialogs.more_info_control.labels",
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
copyable: true,
},
];
const yamlData = {
...(contextEntries.length
? {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
},
}
: {}),
entity: {
entity_id: this.params.entityId,
labels: labelNames,
},
entity_id: this.params.entityId,
...stateYamlData,
};
@@ -171,32 +106,13 @@ 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
.header=${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
>
${this._renderEntries(stateEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.entity"
)}
>
${this._renderEntries(entityEntries)}
${this._renderEntries(stateEntries)}
</ha-grouped-list>
<ha-grouped-list
@@ -222,17 +138,12 @@ class HaMoreInfoDetails extends LitElement {
stateEntries: DetailEntry[];
attributes: { name: string; label: string }[];
yamlData: {
state: {
translated: string;
raw: string;
last_changed: string;
last_updated: string;
};
state: string;
last_changed: string;
last_updated: string;
attributes: Record<string, string>;
};
} => {
const translatedState = this.hass.formatEntityState(stateObj);
const attributes = Object.keys(stateObj.attributes)
.map((a) => ({
name: a,
@@ -245,12 +156,8 @@ class HaMoreInfoDetails extends LitElement {
return {
stateEntries: [
{
translationKey: "ui.dialogs.more_info_control.translated",
value: translatedState,
},
{
translationKey: "ui.dialogs.more_info_control.raw",
value: stateObj.state,
translationKey: "ui.dialogs.more_info_control.state",
value: this.hass.formatEntityState(stateObj),
},
{
translationKey: "ui.dialogs.more_info_control.last_changed",
@@ -263,12 +170,9 @@ class HaMoreInfoDetails extends LitElement {
],
attributes,
yamlData: {
state: {
translated: translatedState,
raw: stateObj.state,
last_changed: stateObj.last_changed,
last_updated: stateObj.last_updated,
},
state: stateObj.state,
last_changed: stateObj.last_changed,
last_updated: stateObj.last_updated,
attributes: stateObj.attributes,
},
};
@@ -284,17 +188,59 @@ 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.copyable) {
return html`
<ha-list-item-value .label=${label}
>${entry.value}</ha-list-item-value
>
`;
}
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>
`;
});
}
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 }[]) {
@@ -337,7 +283,7 @@ class HaMoreInfoDetails extends LitElement {
.filter(([_key, value]) => typeof value === "number")
.map(([key, value]) =>
supportsFeature(stateObj, value as number)
? titleCase(key.replaceAll("_", "\u00A0").toLowerCase())
? titleCase(key.replaceAll("_", " ").toLowerCase())
: undefined
)
.filter(Boolean)
@@ -358,11 +304,37 @@ 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;
}
.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;
}
ha-svg-icon.copy-success {
color: var(--success-color);
}
.empty {
+33 -61
View File
@@ -8,8 +8,8 @@ import {
mdiContentDuplicate,
mdiDevices,
mdiDotsVertical,
mdiFormatListBulletedSquare,
mdiInformationOutline,
mdiLinkVariant,
mdiPencil,
mdiPencilOff,
mdiPencilOutline,
@@ -17,7 +17,6 @@ import {
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { provide } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -28,6 +27,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 +47,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";
@@ -57,7 +60,7 @@ import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-prev";
import "../../components/ha-related-items";
import "./ha-more-info-related";
import type {
EntityRegistryEntry,
ExtEntityRegistryEntry,
@@ -66,8 +69,6 @@ import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import type { EntitySettingsState } from "../../panels/config/entities/entity-registry-settings-editor";
import type { Helper } from "../../panels/config/helpers/const";
@@ -88,7 +89,6 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import { moreInfoContext, type MoreInfoContext } from "./context";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -106,7 +106,6 @@ export interface MoreInfoDialogParams {
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
hash?: URLSearchParams;
fromUrl?: boolean;
returnUrl?: string;
parentElement?: LitElement;
@@ -154,10 +153,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _data?: Record<string, any>;
@provide({ context: moreInfoContext })
@state()
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
private _returnUrl?: string;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@@ -194,7 +189,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._moreInfoContext = this._createMoreInfoContext(params.hash);
this._returnUrl = params.returnUrl;
this._currView = view;
this._initialView = view;
@@ -231,7 +225,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;
@@ -242,7 +242,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childViewStack = [];
this._moreInfoContext = this._createMoreInfoContext();
this._returnUrl = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
@@ -291,10 +290,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return entity?.device_id ?? null;
}
private _setView(view: MoreInfoView, preserveHash = false) {
if (view !== this._currView && !preserveHash) {
this._moreInfoContext = this._createMoreInfoContext();
}
private _setView(view: MoreInfoView) {
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
@@ -313,29 +309,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
createMoreInfoUrl(this._returnUrl, {
entityId: this._entityId,
view: this._currView,
hash: this._moreInfoContext.hash,
})
);
}
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
return {
hash: new URLSearchParams(hash),
setHashParam: (key, value) => this._setHashParam(key, value),
};
}
private _setHashParam(key: string, value?: string) {
const hash = new URLSearchParams(this._moreInfoContext.hash);
if (value) {
hash.set(key, value);
} else {
hash.delete(key);
}
this._moreInfoContext = this._createMoreInfoContext(hash);
this._syncUrl();
}
private _goBack() {
if (this._childView) {
const dialog =
@@ -361,7 +338,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
if (this._parentEntityIds.length > 0) {
this._entityId = this._parentEntityIds.pop();
this._currView = DEFAULT_VIEW;
this._moreInfoContext = this._createMoreInfoContext();
this._loadEntityRegistryEntry();
this._syncUrl();
}
@@ -620,20 +596,22 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const defaultTitle = breadcrumb.pop() || entityId;
const addToTitle = this.hass.localize(
"ui.dialogs.more_info_control.add_to.title",
{ target: defaultTitle }
);
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
const title =
const viewTitle =
this._currView === "details"
? this.hass.localize("ui.dialogs.more_info_control.details")
: this._currView === "add_to"
? addToTitle
: this._childView?.viewTitle || defaultTitle;
: this._currView === "related"
? this.hass.localize("ui.dialogs.more_info_control.related")
: this._currView === "add_to"
? addToMenuItem
: this._childView?.viewTitle;
const defaultTitle = breadcrumb[breadcrumb.length - 1] || entityId;
if (!viewTitle) {
breadcrumb.pop();
}
const title = viewTitle || defaultTitle;
const favoritesContext =
this._entry && stateObj
@@ -870,7 +848,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
<ha-dropdown-item value="related">
<ha-svg-icon
slot="icon"
.path=${mdiInformationOutline}
.path=${mdiLinkVariant}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.related"
@@ -879,7 +857,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
<ha-dropdown-item value="details">
<ha-svg-icon
slot="icon"
.path=${mdiFormatListBulletedSquare}
.path=${mdiInformationOutline}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.details"
@@ -968,15 +946,11 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
`
: this._currView === "related"
? html`
<ha-related-items
<ha-more-info-related
.hass=${this.hass}
.itemId=${entityId}
.itemType=${
SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"
}
></ha-related-items>
.entry=${this._entry}
.params=${{ entityId }}
></ha-more-info-related>
`
: this._currView === "add_to"
? html`
@@ -1068,15 +1042,13 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view, true);
this._setView(view);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
@@ -0,0 +1,307 @@
import { mdiDevices, mdiTextureBox } from "@mdi/js";
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeFloorName } from "../../common/entity/compute_floor_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { LocalizeKeys } from "../../common/translations/localize";
import "../../components/ha-floor-icon";
import "../../components/ha-icon";
import "../../components/ha-icon-next";
import "../../components/ha-label";
import "../../components/ha-related-items";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { ItemType, RelatedResult } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
interface RelatedViewParams {
entityId: string;
}
interface ContextEntry {
translationKey: LocalizeKeys;
value: string;
displayValue?: TemplateResult;
href?: string;
icon?: TemplateResult;
}
const CONTEXT_SECTIONS: (keyof RelatedResult)[] = [
"device",
"area",
"integration",
"config_entry",
];
@customElement("ha-more-info-related")
class HaMoreInfoRelated extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public params?: RelatedViewParams;
@state() private _stateObj?: HassEntity;
@consume({ context: labelsContext, subscribe: true })
@state()
private _labels?: LabelRegistryEntry[];
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("entry") && this.entry) {
this.hass.loadBackendTranslation("title", [this.entry.platform]);
}
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
}
}
}
protected render() {
if (!this.params || !this._stateObj) {
return nothing;
}
const domain = computeDomain(this.params.entityId);
const itemType = SearchableDomains.has(domain)
? (domain as ItemType)
: "entity";
const { floor, area, device } = getEntityContext(
this._stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
const deviceName = device
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
: undefined;
const integrationName = this.entry?.platform
? this.hass.localize(`component.${this.entry.platform}.title`) ||
this.entry.platform
: undefined;
const labels =
this.entry?.labels.map((labelId) => ({
id: labelId,
entry: this._labels?.find((label) => label.label_id === labelId),
})) ?? [];
const contextEntries: ContextEntry[] = [];
if (floor && floorName) {
contextEntries.push({
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) {
contextEntries.push({
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) {
contextEntries.push({
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) {
contextEntries.push({
translationKey: "ui.components.related-items.integration",
value: integrationName,
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
)}
/>`,
});
}
if (labels.length) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.labels",
value: labels.map(({ id, entry }) => entry?.name ?? id).join(", "),
displayValue: 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>`,
});
}
return html`
<div class="content">
${
contextEntries.length
? html`
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
`
: nothing
}
<ha-related-items
.hass=${this.hass}
.itemId=${this.params.entityId}
.itemType=${itemType}
.exclude=${itemType === "entity" ? CONTEXT_SECTIONS : undefined}
></ha-related-items>
</div>
`;
}
private _renderEntries(entries: ContextEntry[]) {
return entries.map((entry) => {
const label = this.hass.localize(entry.translationKey);
if (!entry.href) {
return html`
<ha-list-item-value .label=${label}
>${entry.displayValue ?? entry.value}</ha-list-item-value
>
`;
}
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>
`;
});
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
padding: var(--ha-space-6);
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
ha-grouped-list + ha-related-items {
margin-top: var(--ha-space-6);
}
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);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-related": HaMoreInfoRelated;
}
}
@@ -1,215 +1,43 @@
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-svg-icon";
import "../../../components/input/ha-input";
import type { HaInput } from "../../../components/input/ha-input";
import { cloudLogin } from "../../../data/cloud";
import { showCloudAlreadyConnectedDialog } from "../../../panels/config/cloud/dialog-cloud-already-connected/show-dialog-cloud-already-connected";
import "../../../panels/config/cloud/login/cloud-login";
import type { HomeAssistant } from "../../../types";
import {
showAlertDialog,
showPromptDialog,
} from "../../generic/show-dialog-box";
import { AssistantSetupStyles } from "../styles";
@customElement("cloud-step-signin")
export class CloudStepSignin extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _requestInProgress = false;
@state() private _error?: string;
@state() private _checkConnection = true;
@query("#email", true) private _emailField!: HaInput;
@query("#password", true) private _passwordField!: HaInput;
@property() public email?: string;
render() {
return html`<div class="content">
<img
src=${`/static/images/logo_nabu_casa${this.hass.themes?.darkMode ? "_dark" : ""}.png`}
alt="Nabu Casa logo"
/>
<h1>${this.hass.localize("ui.panel.config.cloud.login.sign_in")}</h1>
${
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"
)}
.disabled=${this._requestInProgress}
type="email"
autocomplete="email"
required
@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"
)}
.disabled=${this._requestInProgress}
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="footer">
<ha-button
@click=${this._handleLogin}
.disabled=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.login.sign_in"
)}</ha-button
>
</div>`;
<img
src=${`/static/images/logo_nabu_casa${this.hass.themes?.darkMode ? "_dark" : ""}.png`}
alt="Nabu Casa logo"
/>
<h1>${this.hass.localize("ui.panel.config.cloud.login.sign_in")}</h1>
<cloud-login
card-less
check-connection
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
@cloud-forgot-password=${this._forgotPassword}
@cloud-logged-in=${this._loggedIn}
></cloud-login>
</div>`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleLogin();
}
private _loggedIn() {
fireEvent(this, "cloud-step", { step: "DONE" });
}
private async _handleLogin() {
const emailField = this._emailField;
const passwordField = this._passwordField;
const email = emailField.value as string;
const password = passwordField.value as string;
if (!emailField.reportValidity()) {
passwordField.reportValidity();
emailField.focus();
return;
}
if (!passwordField.reportValidity()) {
passwordField.focus();
return;
}
this._requestInProgress = true;
const doLogin = async (username: string, code?: string) => {
try {
await cloudLogin({
hass: this.hass,
email: username,
...(code ? { code } : { password }),
check_connection: this._checkConnection,
});
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "mfarequired") {
const totpCode = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.totp_code_prompt_title"
),
inputLabel: this.hass.localize(
"ui.panel.config.cloud.login.totp_code"
),
inputType: "text",
defaultValue: "",
confirmText: this.hass.localize(
"ui.panel.config.cloud.login.submit"
),
});
if (totpCode !== null && totpCode !== "") {
await doLogin(username, totpCode.trim());
return;
}
}
if (errCode === "alreadyconnectederror") {
showCloudAlreadyConnectedDialog(this, {
details: JSON.parse(err.body.message),
logInHereAction: () => {
this._checkConnection = false;
doLogin(username);
},
closeDialog: () => {
this._requestInProgress = false;
},
});
return;
}
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doLogin(username.toLowerCase());
return;
}
if (errCode === "PasswordChangeRequired") {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.alert_password_change_required"
),
});
navigate("/config/cloud/forgot-password");
fireEvent(this, "closed");
return;
}
this._requestInProgress = false;
switch (errCode) {
case "UserNotConfirmed":
this._error = this.hass.localize(
"ui.panel.config.cloud.login.alert_email_confirm_necessary"
);
break;
case "mfarequired":
this._error = this.hass.localize(
"ui.panel.config.cloud.login.alert_mfa_code_required"
);
break;
case "mfaexpiredornotstarted":
this._error = this.hass.localize(
"ui.panel.config.cloud.login.alert_mfa_expired_or_not_started"
);
break;
case "invalidtotpcode":
this._error = this.hass.localize(
"ui.panel.config.cloud.login.alert_totp_code_invalid"
);
break;
default:
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
break;
}
emailField.focus();
}
};
await doLogin(email);
private _forgotPassword() {
navigate("/config/cloud/forgot-password");
fireEvent(this, "closed");
}
static styles = [
@@ -218,6 +46,13 @@ export class CloudStepSignin extends LitElement {
:host {
display: block;
}
.content {
width: 100%;
}
cloud-login {
display: block;
text-align: start;
}
`,
];
}
@@ -1,16 +1,17 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-svg-icon";
import "../../../components/input/ha-input";
import type { HaInput } from "../../../components/input/ha-input";
import "../../../components/ha-spinner";
import type { CloudAutoLogin, CloudStatus } from "../../../data/cloud";
import {
cloudLogin,
cloudRegister,
cloudResendVerification,
cancelCloudAutoLogin,
cloudStatusAutoLogin,
fetchCloudStatus,
subscribeCloudEvents,
} from "../../../data/cloud";
import "../../../panels/config/cloud/register/cloud-register-card";
import type { HomeAssistant } from "../../../types";
import { AssistantSetupStyles } from "../styles";
@@ -18,203 +19,127 @@ import { AssistantSetupStyles } from "../styles";
export class CloudStepSignup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _requestInProgress = false;
@state() private _autoLogin: CloudAutoLogin | null = null;
@state() private _email?: string;
@state() private _loading = true;
@state() private _password?: string;
private _email?: string;
@state() private _error?: string;
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
@state() private _state?: "VERIFY";
private _statusRead: Promise<void> = Promise.resolve();
@query("#email", true) private _emailField!: HaInput;
private _wasLoggedIn?: boolean;
@query("#password", true) private _passwordField!: HaInput;
public connectedCallback(): void {
super.connectedCallback();
this._watchCloudStatus();
this._loadInitialStatus();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
this._unsubCloudEvents = undefined;
}
render() {
return html`<div class="content">
<img
src=${`/static/images/logo_nabu_casa${this.hass.themes?.darkMode ? "_dark" : ""}.png`}
alt="Nabu Casa logo"
/>
<h1>
${this.hass.localize("ui.panel.config.cloud.register.create_account")}
</h1>
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""
}
${
this._state === "VERIFY"
? html`<p>
${this.hass.localize(
"ui.panel.config.cloud.register.confirm_email",
{ email: this._email }
)}
</p>`
: html`<ha-input
autofocus
id="email"
name="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
.disabled=${this._requestInProgress}
type="email"
autocomplete="email"
required
@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"
)}
.disabled=${this._requestInProgress}
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="footer side-by-side">
${
this._state === "VERIFY"
? html`<ha-button
@click=${this._handleResendVerifyEmail}
.disabled=${this._requestInProgress}
appearance="plain"
>${this.hass.localize(
"ui.panel.config.cloud.register.resend_confirm_email"
)}</ha-button
><ha-button
@click=${this._login}
.disabled=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.register.clicked_confirm"
)}</ha-button
>`
: html`<ha-button
@click=${this._signIn}
.disabled=${this._requestInProgress}
appearance="plain"
>${this.hass.localize(
"ui.panel.config.cloud.login.sign_in"
)}</ha-button
>
<ha-button
@click=${this._handleRegister}
.disabled=${this._requestInProgress}
>${this.hass.localize("ui.common.next")}</ha-button
>`
}
</div>`;
<img
src=${`/static/images/logo_nabu_casa${this.hass.themes?.darkMode ? "_dark" : ""}.png`}
alt="Nabu Casa logo"
/>
${
this._loading
? html`<div class="loading"><ha-spinner></ha-spinner></div>`
: html`<cloud-register-card
card-less
.hass=${this.hass}
.autoLogin=${this._autoLogin}
@cloud-email-changed=${this._emailChanged}
@cloud-sign-in-instead=${this._signIn}
@cloud-cancel-auto-login=${this._cancelAutoLogin}
@ha-refresh-cloud-status=${this._refreshCloudStatus}
></cloud-register-card>`
}
</div>`;
}
// The cloud panel is fed status by ha-panel-config; this dialog is mounted at
// the root, outside it, so the step does the watching. Every cloud event means
// one thing here — the state moved, read it back — because cloud/status holds
// everything the events carry.
private _watchCloudStatus() {
if (this._unsubCloudEvents) {
return;
}
const subscription = subscribeCloudEvents(this.hass, () => {
this._refreshCloudStatus();
});
this._unsubCloudEvents = subscription;
subscription.catch(() => {
if (this._unsubCloudEvents === subscription) {
this._unsubCloudEvents = undefined;
}
});
}
private async _loadInitialStatus() {
await this._refreshCloudStatus();
this._loading = false;
}
private _refreshCloudStatus = (): Promise<void> => {
this._statusRead = this._statusRead
.then(() => this._readCloudStatus())
.catch(() => undefined);
return this._statusRead;
};
private async _readCloudStatus(): Promise<void> {
let status: CloudStatus;
try {
status = await fetchCloudStatus(this.hass);
} catch (_err) {
// Nothing to reconcile against. The registration form is the right place
// to be, and the next event tries again.
return;
}
// Lit keeps updating an element that was connected once.
if (!this.isConnected) {
return;
}
const wasLoggedIn = this._wasLoggedIn;
this._wasLoggedIn = status.logged_in;
this._autoLogin = cloudStatusAutoLogin(status);
if (status.logged_in && wasLoggedIn === false) {
fireEvent(this, "cloud-step", { step: "DONE" });
}
}
private _emailChanged(
ev: HASSDomEvent<HASSDomEvents["cloud-email-changed"]>
) {
this._email = ev.detail.value;
}
private _signIn() {
fireEvent(this, "cloud-step", { step: "SIGNIN" });
fireEvent(this, "cloud-step", { step: "SIGNIN", email: this._email });
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleRegister();
}
}
private async _handleRegister() {
const emailField = this._emailField;
const passwordField = this._passwordField;
let invalid = false;
if (!emailField.reportValidity()) {
invalid = true;
emailField.focus();
}
if (!passwordField.reportValidity()) {
if (!invalid) {
passwordField.focus();
}
invalid = true;
}
if (invalid) {
return;
}
const email = emailField.value!.toLowerCase();
const password = passwordField.value!;
this._requestInProgress = true;
private async _cancelAutoLogin() {
try {
await cloudRegister(this.hass, email, password);
this._email = email;
this._password = password;
this._verificationEmailSent();
} catch (err: any) {
this._password = "";
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
await cancelCloudAutoLogin(this.hass);
} catch (_err) {
// Fire and forget: the card has already returned to the form, and the
// read below reconciles a registration the backend refused to drop.
} finally {
this._requestInProgress = false;
}
}
private async _handleResendVerifyEmail() {
if (!this._email) {
return;
}
try {
await cloudResendVerification(this.hass, this._email);
this._verificationEmailSent();
} catch (err: any) {
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
private _verificationEmailSent() {
this._state = "VERIFY";
setTimeout(() => this._login(), 5000);
}
private async _login() {
if (!this._email || !this._password) {
return;
}
try {
await cloudLogin({
hass: this.hass,
email: this._email,
password: this._password,
});
fireEvent(this, "cloud-step", { step: "DONE" });
} catch (e: any) {
if (e?.body?.code === "usernotconfirmed") {
this._verificationEmailSent();
} else {
this._error = "Something went wrong. Please try again.";
}
this._refreshCloudStatus();
}
}
@@ -224,6 +149,14 @@ export class CloudStepSignup extends LitElement {
.content {
width: 100%;
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-8) 0;
}
cloud-register-card {
text-align: start;
}
`,
];
}
@@ -4,6 +4,7 @@ import type { HomeAssistant } from "../../types";
import "./cloud/cloud-step-intro";
import "./cloud/cloud-step-signin";
import "./cloud/cloud-step-signup";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { STEP } from "./voice-assistant-setup-dialog";
@@ -13,6 +14,8 @@ export class HaVoiceAssistantSetupStepCloud extends LitElement {
@state() private _state: "SIGNUP" | "SIGNIN" | "INTRO" = "INTRO";
@state() private _email?: string;
protected override render() {
if (this._state === "SIGNUP") {
return html`<cloud-step-signup
@@ -23,6 +26,7 @@ export class HaVoiceAssistantSetupStepCloud extends LitElement {
if (this._state === "SIGNIN") {
return html`<cloud-step-signin
.hass=${this.hass}
.email=${this._email}
@cloud-step=${this._cloudStep}
></cloud-step-signin>`;
}
@@ -32,7 +36,7 @@ export class HaVoiceAssistantSetupStepCloud extends LitElement {
></cloud-step-intro>`;
}
private _cloudStep(ev) {
private _cloudStep(ev: HASSDomEvent<HASSDomEvents["cloud-step"]>) {
if (ev.detail.step === "DONE") {
fireEvent(this, "next-step", {
step: STEP.PIPELINE,
@@ -40,6 +44,10 @@ export class HaVoiceAssistantSetupStepCloud extends LitElement {
});
return;
}
// Carried between the steps so switching does not lose a typed address.
if (ev.detail.email !== undefined) {
this._email = ev.detail.email;
}
this._state = ev.detail.step;
}
}
@@ -49,6 +57,6 @@ declare global {
"ha-voice-assistant-setup-step-cloud": HaVoiceAssistantSetupStepCloud;
}
interface HASSDomEvents {
"cloud-step": { step: "SIGNUP" | "SIGNIN" | "DONE" };
"cloud-step": { step: "SIGNUP" | "SIGNIN" | "DONE"; email?: string };
}
}
+27
View File
@@ -117,6 +117,33 @@ const initRouting = () => {
})
);
// Strip the rotating token from the cache key, or every rotation refetches
// every tile. The TileJSON is deliberately not matched: it is the switching
// point for the tile source, so it stays on the network.
registerRoute(
({ url }) =>
/^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.test(url.pathname),
new CacheFirst({
cacheName: "map-tiles",
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
url.searchParams.delete("token");
return url.href;
},
},
// A stale token gives a 403; caching that would pin the failure.
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24 * 7,
purgeOnQuotaError: true,
}),
],
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
+171 -41
View File
@@ -9,23 +9,46 @@
* recover this, because the bundle itself failed to load, so this guard has
* to live inline in the HTML document.
*
* On a failed entry load we navigate once to the same URL with a
* cache-busting query param (and, on https, after dropping the service
* worker + its caches) so the browser fetches a fresh index.html that points
* at the current hashes. The param doubles as a one-shot loop guard: if the
* freshly fetched index still fails we stop and show a message instead of
* reloading forever. core.ts strips the param again after a successful boot.
* Only a file the server no longer has means a stale index (see probeGone).
* On a 404 we navigate once to the same URL with a cache-busting query param
* (and, on https, after dropping the service worker + its caches) so the
* browser fetches a fresh index.html that points at the current hashes. One
* reload per minute, tracked in sessionStorage: if it did not help, stop and
* show a message instead of reloading forever.
*
* Never transpiled, so it stays within the [legacy] browserslist floor
* (Chrome 59 / Safari 12): no object spread, Promise.finally, optional
* chaining or AbortController.
*/
(function () {
var BUST_PARAM = "ha_cache_bust";
const BUST_PARAM = "ha_cache_bust";
// Only production entry bundles carry a content hash. Requiring the hash
// keeps the guard inert in development (unhashed core.js / app.js) and
// scoped to the entry chunks whose 404 is fatal to boot - not translations
// or lazily loaded panels, which fail non-fatally and are handled elsewhere.
// Injected from stale-build-patterns.json (the single source shared with
// the bundled util/recover-stale-build.ts) so the two never drift.
var HASHED_ENTRY = new RegExp(<%= JSON.stringify(staleBuildPatterns.hashedEntry) %>, "i");
var MODULE_ERROR = new RegExp(<%= JSON.stringify(staleBuildPatterns.moduleError) %>, "i");
const HASHED_ENTRY = new RegExp(<%= JSON.stringify(staleBuildPatterns.hashedEntry) %>, "i");
const MODULE_ERROR = new RegExp(<%= JSON.stringify(staleBuildPatterns.moduleError) %>, "i");
// The same pattern anchored: a URL is ours only when it *is* an entry path,
// not when it merely contains one (/local/frontend_latest/x.abc12345.js).
const HASHED_PATH = new RegExp("^(?:" + <%= JSON.stringify(staleBuildPatterns.hashedEntry) %> + ")$", "i");
// Any file reference, to tell "a file we do not ship failed" apart from
// "the browser did not say which file failed".
const ANY_URL = new RegExp(<%= JSON.stringify(staleBuildPatterns.anyUrl) %>, "i");
const PROBE_TIMEOUT = 5000;
// Grace period before admitting defeat, so a modulepreload failure that
// the browser retries successfully does not flash the message.
const FATAL_DELAY = 10000;
// This build's own entry bundles, to probe when the browser did not name
// the file that failed (Safari's message names none).
const LATEST_ENTRY_JS = <%= JSON.stringify(latestEntryJS) %>;
const ES5_ENTRY_JS = <%= JSON.stringify(es5EntryJS) %>;
// Shared with the bundled recovery: one reload per minute between them.
const RELOAD_STORAGE_KEY = "haStaleBuildReload";
const RELOAD_COOLDOWN = 60000;
const probing = {};
let recovering = false;
function booted() {
// The launch screen is only removed once the app has taken over the
@@ -36,21 +59,62 @@
}
function showFatal() {
var box = document.getElementById("ha-launch-screen-info-box");
const box = document.getElementById("ha-launch-screen-info-box");
if (box) {
box.textContent =
"Could not load Home Assistant. Please refresh the page, and clear your browser cache if the problem persists.";
}
}
/*
* Claim a recovery reload: one per cooldown window, timed rather than
* counted. The bust param alone is not a
* durable marker: core.ts strips it on every successful connect, so a page
* that boots and then fails on the next load would reload again and again.
* When storage is unavailable we keep the old param-only behaviour rather
* than lose the recovery entirely.
*/
function claimReload() {
const now = Date.now();
let last = 0;
try {
// A missing or unreadable marker reads as 0, i.e. no recent reload; it
// is replaced below rather than handing out a reload on every boot.
last = Number(sessionStorage.getItem(RELOAD_STORAGE_KEY)) || 0;
} catch (_err) {
return true;
}
if (now - last <= RELOAD_COOLDOWN) {
return false;
}
try {
sessionStorage.setItem(RELOAD_STORAGE_KEY, String(now));
} catch (_err) {
// ignore
}
return true;
}
function recover() {
if (recovering) {
// core.js and app.js are confirmed gone moments apart; the first
// verdict is already navigating.
return;
}
if (location.search.indexOf(BUST_PARAM + "=") !== -1) {
// We already reloaded once with a fresh index and it still failed;
// stop to avoid a reload loop.
showFatal();
return;
}
var target =
if (!claimReload()) {
// Reloaded recently (possibly on the previous page load, before the
// param was stripped) and it did not help: stop asking.
showFatal();
return;
}
recovering = true;
const target =
location.pathname +
location.search +
(location.search ? "&" : "?") +
@@ -63,31 +127,21 @@
// stale cached index. Drop the worker and its caches first so the reload
// is served fresh from the network.
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
var go = function () {
const go = () => {
location.replace(target);
};
Promise.all([
navigator.serviceWorker
.getRegistrations()
.then(function (regs) {
return Promise.all(
regs.map(function (r) {
return r.unregister();
})
);
})
.catch(function () {}),
.then((regs) => Promise.all(regs.map((reg) => reg.unregister())))
.catch(() => {}),
self.caches
? caches
.keys()
.then(function (keys) {
return Promise.all(
keys.map(function (k) {
return caches.delete(k);
})
);
})
.catch(function () {})
.then((keys) =>
Promise.all(keys.map((key) => caches.delete(key)))
)
.catch(() => {})
: null,
]).then(go, go);
} else {
@@ -95,26 +149,102 @@
}
}
function maybeRecover(url, message) {
if (booted()) {
/*
* The file a failure is about: its absolute URL when this build ships it,
* "foreign" for anything else (a dashboard resource, an extra_module_url, a
* custom panel - those say nothing about the index being stale, and
* reloading over them never ends), or null when no file was named.
*/
function failedFile(text) {
const match = ANY_URL.exec(text);
if (!match) {
return null;
}
try {
const url = new URL(match[0], location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
} catch (_err) {
// Not a URL after all.
}
return "foreign";
}
function referenceUrl() {
const entries = window.latestJS ? LATEST_ENTRY_JS : ES5_ENTRY_JS;
for (const entry of entries) {
const url = new URL(entry, location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
}
return null;
}
/*
* A failed load looks identical whether the file was deleted by an upgrade
* or the request never reached the server, and only the first is fixed by
* reloading. HEAD is never intercepted by the service worker (workbox
* routes GET only), so the answer comes from the server itself. A server
* that refuses HEAD outright leaves us with no answer, and no answer means
* no recovery: a GET cannot substitute, because the frontend_latest route
* is CacheFirst with `ignoreSearch`, so a controlling worker can answer it
* from its own cache.
*/
function probeGone(url) {
// Status 0 stands for "no usable answer": unreachable, or too slow.
return Promise.race([
fetch(url, { method: "HEAD", cache: "no-store" }).then(
(response) => response.status,
() => 0
),
new Promise((resolve) => {
setTimeout(() => resolve(0), PROBE_TIMEOUT);
}),
]).then((status) => status === 404 || status === 410);
}
function maybeRecover(text) {
if (booted() || (!HASHED_ENTRY.test(text) && !MODULE_ERROR.test(text))) {
return;
}
if (
(url && HASHED_ENTRY.test(url)) ||
(message && (HASHED_ENTRY.test(message) || MODULE_ERROR.test(message)))
) {
recover();
const failed = failedFile(text);
if (failed === "foreign") {
return;
}
// Probe per URL: core.js and app.js fail as separate events, and one of
// them being served says nothing about the other.
const target = failed || referenceUrl();
if (!target || probing[target]) {
return;
}
probing[target] = true;
probeGone(target).then((gone) => {
delete probing[target];
if (gone) {
recover();
return;
}
// Nothing was deleted, so a reload cannot help - but the file that
// failed is not coming back either. Say so if the app never takes over,
// rather than leaving an empty launch screen up for good.
setTimeout(() => {
if (!booted() && !recovering) {
showFatal();
}
}, FATAL_DELAY);
});
}
// Resource-load errors (<script>, modulepreload <link>) do not bubble, so
// they are only observable in the capture phase at the window.
window.addEventListener(
"error",
function (ev) {
var el = ev.target;
(ev) => {
const el = ev.target;
if (el && (el.tagName === "SCRIPT" || el.tagName === "LINK")) {
maybeRecover(el.href || el.src, null);
maybeRecover(el.href || el.src || "");
}
},
true
@@ -122,9 +252,9 @@
// A failed dynamic import() rejects; the inline entry imports do not catch
// it, so the rejection surfaces here.
window.addEventListener("unhandledrejection", function (ev) {
var reason = ev && ev.reason;
maybeRecover(null, reason && (reason.message || String(reason)));
window.addEventListener("unhandledrejection", (ev) => {
const reason = ev && ev.reason;
maybeRecover((reason && (reason.message || String(reason))) || "");
});
})();
</script>
+4 -1
View File
@@ -155,7 +155,10 @@
<% if (obj.hassUrl === "") { %>
<script>
{%- for extra_module in extra_modules -%}
import("{{ extra_module }}");
// Caught, or the boot recovery guard reads it as a stale build.
import("{{ extra_module }}").catch(function (err) {
console.error("Failed to load extra module {{ extra_module }}", err);
});
{%- endfor -%}
</script>
<% } %>
+28 -6
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) {
@@ -187,7 +202,7 @@ export class HassRouterPage extends ReactiveElement {
// the app stayed open) is recoverable: reload onto the current build
// (or prompt when there are unsaved edits) instead of dead-ending.
const message = err instanceof Error ? err.message : String(err ?? "");
const stale = recoverFromStaleBuild(message, this);
const recovery = recoverFromStaleBuild(message, this);
// Show error screen, offering a reload action for a stale build. Set
// `showReload` on the returned element rather than through
@@ -196,8 +211,12 @@ export class HassRouterPage extends ReactiveElement {
const errorScreen = this.createErrorScreen(
`Error while loading page ${newPage}.`
);
errorScreen.showReload = stale;
this.appendChild(errorScreen);
// That action drops the caches, so only offer it once the probe has
// confirmed the chunk is really gone.
void Promise.resolve(recovery).then((stale) => {
errorScreen.showReload = stale;
});
});
// If we don't show loading screen, just show the panel.
@@ -365,7 +384,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;
}
}
+1 -5
View File
@@ -318,10 +318,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (!searchParams["more-info-entity-id"]) {
return;
}
const { entityId, view, hash } = decodeMoreInfoUrl(
window.location.search,
window.location.hash
);
const { entityId, view } = decodeMoreInfoUrl(window.location.search);
if (!entityId) {
return;
}
@@ -331,7 +328,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
showMoreInfoDialog(this, {
entityId,
view,
hash,
fromUrl: true,
});
});
@@ -1462,29 +1462,34 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
return;
}
let removeData = false;
const _removeDataToggled = (e: Event) => {
removeData = (e.target as HaSwitch).checked;
let removeConfig = false;
const _removeConfigToggled = (e: Event) => {
removeConfig = (e.target as HaSwitch).checked;
};
const confirmed = await showConfirmationDialog(this, {
title: this.i18n.localize(
"ui.panel.config.apps.dashboard.uninstall_dialog.title",
{
name: getAppDisplayName(addon.name, addon.stage),
}
"ui.panel.config.apps.dashboard.uninstall_dialog.title"
),
text: html`
<p>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.uninstall_dialog.text",
{
name: getAppDisplayName(addon.name, addon.stage),
}
)}
</p>
<ha-formfield
.label=${html`<p>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.uninstall_dialog.remove_data"
"ui.panel.config.apps.dashboard.uninstall_dialog.remove_config"
)}
</p>`}
>
<ha-switch
@change=${_removeDataToggled}
.checked=${removeData}
@change=${_removeConfigToggled}
.checked=${removeConfig}
haptic
></ha-switch>
</ha-formfield>
@@ -1503,7 +1508,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
this._uninstalling = true;
this._error = undefined;
try {
await uninstallHassioAddon(this.api.callWS, addon.slug, removeData);
await uninstallHassioAddon(this.api.callWS, addon.slug, removeConfig);
const eventdata = {
success: true,
response: undefined,
@@ -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", {
@@ -685,6 +685,8 @@ class DialogAddAutomationElement
automationElementType,
this.hass.localize,
this.hass.services,
this._triggerDescriptions,
this._conditionDescriptions,
this._manifests
)}
.convertToItem=${this._convertToItem}
@@ -1034,6 +1036,8 @@ class DialogAddAutomationElement
this._selectedCollectionIndex ?? 0,
this.hass.localize,
this.hass.services,
this._triggerDescriptions,
this._conditionDescriptions,
this._manifests,
this._systemDomains?.byEntityDomain
),
@@ -1071,6 +1075,8 @@ class DialogAddAutomationElement
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc,
services: HomeAssistant["services"],
triggerDescriptions: TriggerDescriptions,
conditionDescriptions: ConditionDescriptions,
manifests?: DomainManifestLookup
): AddAutomationElementListItem[] => {
const groups = this._getGroups(type);
@@ -1087,14 +1093,12 @@ class DialogAddAutomationElement
const items = flattenGroups(groups).flat();
if (type === "trigger") {
items.push(
...this._triggers(localize, this._triggerDescriptions, undefined)
);
items.push(...this._triggers(localize, triggerDescriptions, undefined));
} else if (type === "condition") {
items.push(
...this._conditions(
localize,
this._conditionDescriptions,
conditionDescriptions,
manifests,
undefined
)
@@ -1253,13 +1257,15 @@ class DialogAddAutomationElement
collectionIndex: number,
localize: LocalizeFunc,
services: HomeAssistant["services"],
triggerDescriptions: TriggerDescriptions,
conditionDescriptions: ConditionDescriptions,
manifests?: DomainManifestLookup,
systemDomainsByEntityDomain?: Map<string, Set<string>>
): AddAutomationElementListItem[] => {
if (type === "trigger" && isDynamic(group)) {
return this._triggers(
localize,
this._triggerDescriptions,
triggerDescriptions,
systemDomainsByEntityDomain,
group
);
@@ -1267,7 +1273,7 @@ class DialogAddAutomationElement
if (type === "condition" && isDynamic(group)) {
return this._conditions(
localize,
this._conditionDescriptions,
conditionDescriptions,
manifests,
systemDomainsByEntityDomain,
group
@@ -1284,9 +1290,17 @@ class DialogAddAutomationElement
let result: AddAutomationElementListItem[];
const descriptions =
type === "condition" ? conditionDescriptions : triggerDescriptions;
if (groupDef?.domains && !groupDef.members) {
// Curated group whose items come solely from backend domains (e.g. Sun).
result = this._getDomainElementItems(type, groupDef.domains, localize);
result = this._getDomainElementItems(
type,
groupDef.domains,
localize,
descriptions
);
} else {
const groups = this._getGroups(type, group, collectionIndex);
result = Object.entries(groups).map(([key, options]) =>
@@ -1295,7 +1309,12 @@ class DialogAddAutomationElement
if (groupDef?.domains) {
// Curated group with both static members and backend domains (Time).
result.push(
...this._getDomainElementItems(type, groupDef.domains, localize)
...this._getDomainElementItems(
type,
groupDef.domains,
localize,
descriptions
)
);
}
}
@@ -1673,18 +1692,19 @@ class DialogAddAutomationElement
private _getDomainElementItems(
type: AddAutomationElementDialogParams["type"],
domains: string[],
localize: LocalizeFunc
localize: LocalizeFunc,
descriptions: TriggerDescriptions | ConditionDescriptions
): AddAutomationElementListItem[] {
const domainSet = new Set(domains);
if (type === "trigger") {
return Object.keys(this._triggerDescriptions)
return Object.keys(descriptions)
.filter((trigger) => domainSet.has(getTriggerDomain(trigger)))
.map((trigger) =>
this._getTriggerListItem(localize, getTriggerDomain(trigger), trigger)
);
}
if (type === "condition") {
return Object.keys(this._conditionDescriptions)
return Object.keys(descriptions)
.filter((condition) => domainSet.has(getConditionDomain(condition)))
.map((condition) =>
this._getConditionListItem(
@@ -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 {
@@ -1,6 +1,7 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/ha-alert";
@@ -71,11 +72,11 @@ export class CloudLoginPanel extends LitElement {
cloudLogin.emailField?.focus();
}
private _handleForgotPassword() {
private _handleForgotPassword(
ev: HASSDomEvent<HASSDomEvents["cloud-forgot-password"]>
) {
this._dismissFlash();
fireEvent(this, "cloud-email-changed", {
value: this._cloudLoginElement?.emailField?.value ?? this.email ?? "",
});
fireEvent(this, "cloud-email-changed", { value: ev.detail.email });
navigate("/config/cloud/forgot-password");
}
+5 -1
View File
@@ -226,6 +226,7 @@ export class CloudLogin extends LitElement {
}
this.email = "";
fireEvent(this, "ha-refresh-cloud-status");
fireEvent(this, "cloud-logged-in");
} catch (err: any) {
const error = await this._handleCloudLoginError(
err,
@@ -283,7 +284,9 @@ export class CloudLogin extends LitElement {
}
private _handleForgotPassword() {
fireEvent(this, "cloud-forgot-password");
fireEvent(this, "cloud-forgot-password", {
email: this.emailField?.value ?? this.email ?? "",
});
}
static get styles() {
@@ -325,5 +328,6 @@ declare global {
"cloud-forgot-password": {
email: string;
};
"cloud-logged-in": undefined;
}
}
@@ -0,0 +1,627 @@
import { mdiEmailCheckOutline } from "@mdi/js";
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 "../../../../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 type { CloudAutoLogin } from "../../../../data/cloud";
import {
attemptCloudAutoLoginNow,
cloudRegisterAutoLogin,
resendCloudAutoLoginConfirm,
} from "../../../../data/cloud";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
// Registration with email-confirmation auto login. Owns the whole flow so the
// cloud panel page and the voice satellite wizard can present it in their own
// chrome; hosts supply the pending registration, cancel it on the backend when
// asked to, and route the way out.
@customElement("cloud-register-card")
export class CloudRegisterCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public email?: string;
@property({ attribute: false }) public autoLogin: CloudAutoLogin | null =
null;
@property({ type: Boolean, attribute: "card-less" }) public cardLess = false;
@state() private _requestInProgress = false;
@state() private _resendInProgress = false;
@state() private _attemptInProgress = false;
@state() private _autoLoginError?: string;
@state() private _error?: string;
@state() private _success?: string;
@state() private _registeredEmail = "";
@state() private _override?: "form" | "confirm";
private _failedKey: string | null = null;
private _confirmingReported?: boolean;
@query("#email") private _emailField?: HaInput;
@query("#password") private _passwordField?: HaInput;
@query("#confirm-title") private _confirmTitle?: HTMLElement;
private get _confirming(): boolean {
return this._override
? this._override === "confirm"
: this.autoLogin !== null;
}
private get _pendingEmail(): string {
return this._registeredEmail || this.autoLogin?.email || "";
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!changedProps.has("autoLogin")) {
return;
}
if (this._override === (this.autoLogin ? "confirm" : "form")) {
// The status caught up with the local action, so stop overriding it.
this._override = undefined;
}
const failedKey = this.autoLogin?.failed ?? null;
if (failedKey !== this._failedKey) {
this._failedKey = failedKey;
this._resolveAutoLoginError(failedKey);
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
const confirming = this._confirming;
if (confirming !== this._confirmingReported) {
this._confirmingReported = confirming;
fireEvent(this, "cloud-register-view-changed", { confirming });
}
}
protected render(): TemplateResult {
return this._confirming ? this._renderConfirm() : this._renderForm();
}
private _renderForm(): TemplateResult {
const title = this.hass.localize(
"ui.panel.config.cloud.register.create_account"
);
const content = html`
<div class="card-header-block">
${this.cardLess ? html`<h1>${title}</h1>` : html`<h2>${title}</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>
`;
return this.cardLess
? content
: html`<ha-card outlined>${content}</ha-card>`;
}
private _renderConfirm(): TemplateResult {
const title = this.hass.localize(
"ui.panel.config.cloud.register.check_your_email"
);
const content = html`
<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>
${
this.cardLess
? html`<h1 id="confirm-title" tabindex="-1">${title}</h1>`
: html`<h2 id="confirm-title" tabindex="-1">${title}</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-progress-button
@click=${this._handleAttemptNow}
.progress=${this._attemptInProgress}
>
${this.hass.localize(
"ui.panel.config.cloud.register.clicked_confirm"
)}
</ha-progress-button>`
}
</div>
`;
return html`
${this.cardLess ? content : html`<ha-card outlined>${content}</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._confirming;
const fieldValue = this._emailField?.value;
fireEvent(this, "cloud-email-changed", {
value:
fieldValue !== undefined
? fieldValue
: this._pendingEmail || this.email || "",
});
if (confirming) {
this._dismiss();
fireEvent(this, "cloud-cancel-auto-login");
}
fireEvent(this, "cloud-sign-in-instead");
}
private _handleCancelPending() {
this._dismiss();
fireEvent(this, "cloud-cancel-auto-login");
}
private _dismiss() {
this._override = "form";
this._registeredEmail = "";
this._error = undefined;
this._success = undefined;
this._clearAutoLoginFailure();
}
private _clearAutoLoginFailure() {
this._failedKey = null;
this._autoLoginError = undefined;
}
private async _handleRegister() {
const emailField = this._emailField;
const passwordField = this._passwordField;
if (!emailField || !passwordField) {
return;
}
if (!emailField.reportValidity()) {
passwordField.reportValidity();
emailField.focus();
return;
}
if (!passwordField.reportValidity()) {
passwordField.focus();
return;
}
const email = emailField.value?.toLowerCase() || "";
const password = passwordField.value || "";
this._requestInProgress = true;
this._error = undefined;
try {
await cloudRegisterAutoLogin(this.hass, email, password);
} catch (err: any) {
this._requestInProgress = false;
passwordField.value = "";
this._error =
err?.body?.message || this.hass.localize("ui.common.unknown_error");
return;
}
this._registeredEmail = email;
this._requestInProgress = false;
this._override = "confirm";
this._clearAutoLoginFailure();
fireEvent(this, "cloud-email-changed", { value: email });
fireEvent(this, "cloud-auto-login-started");
fireEvent(this, "ha-refresh-cloud-status");
await this.updateComplete;
this._confirmTitle?.focus();
}
private async _localizeBackendMessage(
key: string,
domain = "cloud",
placeholders?: Record<string, string>
): Promise<string | undefined> {
const localize = await this.hass.loadBackendTranslation(
"exceptions",
domain
);
return (
localize(`component.${domain}.exceptions.${key}.message`, placeholders) ||
undefined
);
}
private async _websocketErrorMessage(err: any): Promise<string> {
if (err?.translation_key) {
const message = await this._localizeBackendMessage(
err.translation_key,
err.translation_domain,
err.translation_placeholders
);
if (message) {
return message;
}
}
return err?.message || this.hass.localize("ui.common.unknown_error");
}
private async _resolveAutoLoginError(key: string | null) {
if (!key) {
this._autoLoginError = undefined;
return;
}
let reason: string | undefined;
try {
reason = await this._localizeBackendMessage(key);
} catch (_err) {
// The translation load rejects when the socket drops mid-round-trip. The
// generic reason still has to land: the status reports this failure once
// and nothing else would take the user off the waiting view.
}
if (this._failedKey !== key) {
// A newer status superseded this failure while the lookup was in flight.
return;
}
this._error = undefined;
this._success = undefined;
this._autoLoginError =
reason ||
this.hass.localize("ui.panel.config.cloud.register.auto_login_failed");
}
private async _handleAttemptNow() {
if (this._attemptInProgress) {
return;
}
this._attemptInProgress = true;
try {
await attemptCloudAutoLoginNow(this.hass);
} catch (err: any) {
showToast(this, { message: await this._websocketErrorMessage(err) });
return;
} finally {
this._attemptInProgress = false;
}
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,
cloudSignedOutStyle,
css`
:host {
display: block;
}
:host([card-less]) .card-header-block,
:host([card-less]) .confirm {
padding-inline: 0;
}
:host([card-less]) .card-header-block h1 {
margin: 0;
text-align: center;
}
:host([card-less]) .confirm h1 {
margin: 0;
}
.card-header-block {
padding: var(--ha-space-4) var(--ha-space-4) 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;
}
.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%;
margin: var(--ha-space-3) auto 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
text-wrap: pretty;
}
@media (max-width: 500px) {
.card-actions.split {
flex-direction: column;
align-items: stretch;
gap: var(--ha-space-2);
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-register-card": CloudRegisterCard;
}
interface HASSDomEvents {
"cloud-cancel-auto-login": undefined;
"cloud-auto-login-started": undefined;
"cloud-register-view-changed": { confirming: boolean };
"cloud-sign-in-instead": undefined;
}
}
@@ -1,32 +1,18 @@
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 type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
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 type { CloudAutoLogin, CloudStatus } from "../../../../data/cloud";
import {
attemptCloudAutoLoginNow,
cloudRegisterAutoLogin,
resendCloudAutoLoginConfirm,
subscribeCloudEvents,
} from "../../../../data/cloud";
import type { CloudStatus } from "../../../../data/cloud";
import { cloudStatusAutoLogin } from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import "../cloud-signed-out-menu";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
import "./cloud-register-card";
const BACK_PATH = "/config/cloud/start";
@@ -40,76 +26,15 @@ export class CloudRegister extends LitElement {
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@state() private _view: "form" | "confirm" = "form";
@state() private _requestInProgress = false;
@state() private _resendInProgress = false;
@state() private _autoLoginError?: string;
@state() private _error?: string;
@state() private _success?: string;
@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();
}
@state() private _confirming = false;
protected render(): TemplateResult {
const confirming = this._view === "confirm";
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path=${BACK_PATH}
.backCallback=${confirming ? this._handleConfirmBack : undefined}
.backCallback=${this._confirming ? this._handleConfirmBack : undefined}
.header=${this.hass.localize("ui.panel.config.cloud.register.headline")}
>
<cloud-signed-out-menu
@@ -117,406 +42,36 @@ export class CloudRegister extends LitElement {
.hass=${this.hass}
></cloud-signed-out-menu>
<div class="content">
${confirming ? this._renderConfirm() : this._renderForm()}
<cloud-register-card
.hass=${this.hass}
.email=${this.email}
.autoLogin=${cloudStatusAutoLogin(this.cloudStatus)}
@cloud-register-view-changed=${this._viewChanged}
@cloud-sign-in-instead=${this._handleSignInInstead}
></cloud-register-card>
</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 _viewChanged(
ev: HASSDomEvent<HASSDomEvents["cloud-register-view-changed"]>
) {
this._confirming = ev.detail.confirming;
}
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 });
navigate("/config/cloud/login", { replace: this._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();
return;
}
if (!passwordField.reportValidity()) {
passwordField.focus();
return;
}
const email = emailField.value?.toLowerCase() || "";
const password = passwordField.value || "";
this._requestInProgress = true;
this._error = undefined;
try {
await cloudRegisterAutoLogin(this.hass, email, password);
} catch (err: any) {
this._requestInProgress = false;
passwordField.value = "";
this._error =
err?.body?.message || this.hass.localize("ui.common.unknown_error");
return;
}
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 _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,
@@ -526,106 +81,10 @@ export class CloudRegister extends LitElement {
.content {
gap: var(--ha-space-3);
}
.card-header-block {
padding: var(--ha-space-4) var(--ha-space-4) 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;
}
.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;
cloud-register-card {
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);
}
margin-inline: auto;
}
`,
];
@@ -636,9 +95,4 @@ declare global {
interface HTMLElementTagNameMap {
"cloud-register": CloudRegister;
}
interface HASSDomEvents {
"cloud-cancel-auto-login": undefined;
"cloud-auto-login-started": undefined;
}
}
+18 -7
View File
@@ -1,4 +1,5 @@
import {
mdiAccessPointNetwork,
mdiAccount,
mdiBackupRestore,
mdiBadgeAccountHorizontal,
@@ -92,13 +93,6 @@ export const configSections: Record<string, PageNavigation[]> = {
component: "lovelace",
adminOnly: true,
},
{
path: "/config/voice-assistants",
translationKey: "voice_assistants",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
dashboard_external_settings: [
{
@@ -109,6 +103,23 @@ export const configSections: Record<string, PageNavigation[]> = {
},
],
dashboard_2: [
{
path: "/config/connectivity",
translationKey: "connectivity",
iconPath: mdiAccessPointNetwork,
iconColor: "#00838F",
core: true,
adminOnly: true,
},
{
path: "/config/voice-assistants",
translationKey: "voice_assistants",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
connectivity: [
{
path: "/config/matter",
iconPath:
@@ -0,0 +1,80 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-card";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { configSections } from "../config-sections";
import "../dashboard/ha-config-navigation";
import "../ha-config-section";
@customElement("ha-config-connectivity")
class HaConfigConnectivity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
protected render(): TemplateResult {
const title = this.hass.localize(
"ui.panel.config.dashboard.connectivity.main"
);
return html`
<hass-subpage
.hass=${this.hass}
back-path="/config"
.header=${title}
.narrow=${this.narrow}
>
<ha-config-section .isWide=${this.isWide} full-width>
<ha-card outlined>
<ha-config-navigation
.hass=${this.hass}
.narrow=${this.narrow}
.pages=${configSections.connectivity}
.label=${title}
></ha-config-navigation>
</ha-card>
</ha-config-section>
</hass-subpage>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-config-section {
margin: auto;
margin-top: -32px;
max-width: 600px;
}
ha-card {
overflow: hidden;
margin-bottom: max(24px, var(--safe-area-inset-bottom));
}
@media all and (max-width: 600px) {
ha-card {
border-width: 1px 0;
border-radius: var(--ha-border-radius-square);
box-shadow: unset;
}
ha-config-section {
margin-top: -42px;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-connectivity": HaConfigConnectivity;
}
}
@@ -24,6 +24,8 @@ class HaConfigNavigation extends LitElement {
@property({ attribute: false }) public pages!: PageNavigation[];
@property() public label?: string;
@state() private _visiblePages?: PageNavigation[];
private _hasBluetoothConfigEntries = false;
@@ -99,16 +101,15 @@ class HaConfigNavigation extends LitElement {
}
`,
}));
const label = this.label ?? this.hass.localize("panel.config");
return html`
<div class="visually-hidden" role="heading" aria-level="2">
${this.hass.localize("panel.config")}
</div>
<div class="visually-hidden" role="heading" aria-level="2">${label}</div>
<ha-config-navigation-list
has-secondary
.hass=${this.hass}
.narrow=${this.narrow}
.pages=${pages}
.label=${this.hass.localize("panel.config")}
.label=${label}
></ha-config-navigation-list>
`;
}
@@ -201,7 +201,7 @@ export class DialogVacuumSegmentMapping
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
+37 -18
View File
@@ -2,6 +2,7 @@ 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 type { HASSDomEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { CloudStatus } from "../../data/cloud";
import { fetchCloudStatus, subscribeCloudEvents } from "../../data/cloud";
@@ -18,6 +19,12 @@ declare global {
interface HASSDomEvents {
"ha-refresh-cloud-status": undefined;
}
interface GlobalEventHandlersEventMap {
"ha-refresh-cloud-status": HASSDomEvent<
HASSDomEvents["ha-refresh-cloud-status"]
>;
}
}
@customElement("ha-panel-config")
@@ -63,6 +70,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-cloud",
load: () => import("./cloud/ha-config-cloud"),
},
connectivity: {
tag: "ha-config-connectivity",
load: () => import("./connectivity/ha-config-connectivity"),
},
devices: {
tag: "ha-config-devices",
load: () => import("./devices/ha-config-devices"),
@@ -273,7 +284,19 @@ class HaPanelConfig extends HassRouterPage {
this._wideSidebar = matches;
})
);
if (this.hass && isComponentLoaded(this.hass.config, "cloud")) {
this._listenOnWindow("ha-refresh-cloud-status", () => {
if (this._cloudLoaded()) {
this._updateCloudStatus();
}
});
this._listenOnWindow("connection-status", (ev) => {
if (ev.detail === "connected" && this._cloudLoaded()) {
this._updateCloudStatus();
this._subscribeCloudEvents();
}
});
if (this._cloudLoaded()) {
this._subscribeCloudEvents();
this._updateCloudStatus();
}
@@ -290,12 +313,20 @@ class HaPanelConfig extends HassRouterPage {
entityRegistryById.clear();
}
private _cloudLoaded(): boolean {
return !!this.hass && isComponentLoaded(this.hass.config, "cloud");
}
private _listenOnWindow<EventName extends keyof GlobalEventHandlersEventMap>(
type: EventName,
listener: (ev: GlobalEventHandlersEventMap[EventName]) => void
) {
window.addEventListener(type, listener);
this._listeners.push(() => window.removeEventListener(type, listener));
}
private _subscribeCloudEvents() {
if (
this._unsubCloudEvents ||
!this.hass ||
!isComponentLoaded(this.hass.config, "cloud")
) {
if (this._unsubCloudEvents || !this._cloudLoaded()) {
return;
}
@@ -315,18 +346,6 @@ class HaPanelConfig extends HassRouterPage {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
this.hass.loadBackendTranslation("services");
if (isComponentLoaded(this.hass.config, "cloud")) {
this.addEventListener("connection-status", (ev) => {
if (ev.detail === "connected") {
this._updateCloudStatus();
this._subscribeCloudEvents();
}
});
}
this.addEventListener("ha-refresh-cloud-status", () =>
this._updateCloudStatus()
);
this.style.setProperty(
"--app-header-background-color",
"var(--sidebar-background-color)"
@@ -160,7 +160,7 @@ export class BluetoothConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.bluetooth.title")}
back-path="/config"
back-path="/config/connectivity"
>
<div class="container">
<ha-card class="content network-status">
@@ -52,7 +52,7 @@ export class InfraredConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.infrared.title")}
back-path="/config"
back-path="/config/connectivity"
>
<div class="container">
<ha-card class="content network-status">
@@ -83,7 +83,7 @@ export class MatterConfigDashboard extends LitElement {
.narrow=${this.narrow}
.hass=${this.hass}
header="Matter"
back-path="/config"
back-path="/config/connectivity"
has-fab
>
<div class="container">
@@ -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;
@@ -43,7 +43,7 @@ export class RadioFrequencyConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.radio_frequency.title")}
back-path="/config"
back-path="/config/connectivity"
>
<div class="container">
<ha-card class="network-status">
@@ -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>
@@ -394,7 +409,7 @@ export class SerialConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.serial.title")}
back-path="/config"
back-path="/config/connectivity"
>
<ha-icon-button
slot="toolbar-icon"
@@ -79,7 +79,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
.hass=${this.hass}
header="Thread"
back-path="/config"
back-path="/config/connectivity"
>
<ha-dropdown slot="toolbar-icon">
<ha-icon-button
@@ -101,7 +101,7 @@ class ZHAConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.zha.network.caption")}
back-path="/config"
back-path="/config/connectivity"
>
<div class="loading">
<ha-spinner></ha-spinner>
@@ -129,7 +129,7 @@ class ZHAConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.zha.network.caption")}
back-path="/config"
back-path="/config/connectivity"
has-fab
>
<div class="container">
@@ -148,7 +148,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
.header=${this.hass.localize(
"ui.panel.config.zwave_js.navigation.general"
)}
back-path="/config"
back-path="/config/connectivity"
has-fab
>
<ha-icon-button
+1 -1
View File
@@ -337,7 +337,7 @@ export class SystemLogCard extends LitElement {
}
.secondary {
display: inline-block;
unicode-bidi: isolate;
direction: var(--direction);
}
@@ -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 -1
View File
@@ -191,7 +191,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
back-path="/config/connectivity"
.route=${this.route}
.tabs=${configSections.tags}
.columns=${this._columns(this.hass.localize)}
+19 -19
View File
@@ -20,7 +20,6 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { shallowEqual } from "../../common/util/shallow-equal";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
@@ -31,6 +30,7 @@ import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
import { shallowEqual } from "../../common/util/shallow-equal";
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
import "../../components/chart/state-history-charts";
import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
@@ -40,20 +40,20 @@ import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-icon-button";
import type { SourceFilters } from "../../components/ha-sources-picker";
import {
applySourceFilters,
countSourceFilters,
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import "../../components/ha-spinner";
import "../../components/ha-top-app-bar-fixed";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import type { HistoryResult } from "../../data/history";
import {
computeHistory,
@@ -66,8 +66,8 @@ import { resolveEntityIDs } from "../../data/selector";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { csvDownload, csvSafeString } from "../../util/csv";
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
import { csvSafeString, csvDownload } from "../../util/csv";
const EMPTY_STATES: HomeAssistant["states"] = {};
@@ -266,7 +266,6 @@ class HaPanelHistory extends LitElement {
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
@@ -295,13 +294,19 @@ class HaPanelHistory extends LitElement {
: "ui.panel.history.start_search"
)}
>
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize(
hasTargets
? "ui.panel.history.change_sources"
: "ui.panel.history.add_targets"
)}
</ha-button>
${
!this._sourcesShown()
? html`
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize(
hasTargets
? "ui.panel.history.change_sources"
: "ui.panel.history.add_targets"
)}
</ha-button>
`
: nothing
}
</ha-empty-state>
`;
}
@@ -810,12 +815,7 @@ class HaPanelHistory extends LitElement {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
padding: 16px;
}
.progress-wrapper {
@@ -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;
+28 -3
View File
@@ -1,8 +1,9 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { live } from "lit/directives/live";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { computeDomain } from "../../../common/entity/compute_domain";
import "../../../components/ha-card";
@@ -21,6 +22,7 @@ import type { PersonEntity } from "../../../data/person";
import {
isMediaSourceContentId,
resolveMediaSource,
isStreamingMedia,
} from "../../../data/media_source";
@customElement("hui-picture-card")
@@ -43,6 +45,25 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
@state() private _resolvedImage?: string;
private _reconnectImg = false;
@query("img") private _img?: HTMLImageElement;
public disconnectedCallback() {
super.disconnectedCallback();
if (isStreamingMedia(this._img?.getAttribute("src") || "")) {
this._reconnectImg = true;
this._img?.removeAttribute("src");
}
}
public connectedCallback() {
super.connectedCallback();
if (this._reconnectImg) {
this.requestUpdate();
}
}
public getCardSize(): number {
return 5;
}
@@ -69,7 +90,8 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
if (
!this._config ||
hasConfigChanged(this, changedProps) ||
changedProps.has("_resolvedImage")
changedProps.has("_resolvedImage") ||
this._reconnectImg
) {
return true;
}
@@ -93,6 +115,9 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
if (!this._config || !this.hass) {
return;
}
if (this.isConnected) {
this._reconnectImg = false;
}
const firstHass =
changedProps.has("hass") && changedProps.get("hass") === undefined;
@@ -198,7 +223,7 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
alt=${ifDefined(
this._config.alt_text || stateObj?.attributes.friendly_name
)}
src=${this.hass.hassUrl(image)}
src=${this._reconnectImg ? nothing : live(this.hass.hassUrl(image))}
/>
</ha-card>
`;
@@ -0,0 +1,32 @@
import { computeDomain } from "../../../../common/entity/compute_domain";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
PictureEntityCardConfig,
TileCardConfig,
} from "../../cards/types";
export const computeFavoriteCardConfig = (
entityId: string
): LovelaceCardConfig => {
// A camera tile would only show a thumbnail, so give the picture the room.
if (computeDomain(entityId) === "camera") {
return {
type: "picture-entity",
entity: entityId,
show_name: false,
show_state: false,
grid_options: {
columns: 6,
rows: 2,
},
} satisfies PictureEntityCardConfig;
}
return {
type: "tile",
entity: entityId,
// Favorites come from all over the home, so name the area.
state_content: ["state", "area_name"],
show_entity_picture: true,
} satisfies TileCardConfig;
};
@@ -34,6 +34,7 @@ import type {
TileCardConfig,
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
@@ -271,15 +272,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
column_span: maxColumns,
cards: [
favoritesHeadingCard,
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
...favoriteEntities.map(computeFavoriteCardConfig),
],
};
}
@@ -4,8 +4,9 @@ import { isComponentLoaded } from "../../../../common/config/is_component_loaded
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { getCommonControlsUsagePrediction } from "../../../../data/usage_prediction";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig, TileCardConfig } from "../../cards/types";
import type { HeadingCardConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import type { LovelaceStrategyDependency } from "../types";
const DEFAULT_LIMIT = 8;
@@ -25,13 +26,6 @@ export interface CommonControlsSectionStrategyConfig {
title_visibilty?: Condition[];
}
const toTileCard = (entity: string): TileCardConfig => ({
type: "tile",
entity,
state_content: ["state", "area_name"],
show_entity_picture: true,
});
@customElement("common-controls-section-strategy")
export class CommonControlsSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
@@ -63,7 +57,9 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
// Pinned entities already fill the section, skip the prediction call.
if (includedEntities.length >= limit) {
section.cards!.push(...includedEntities.slice(0, limit).map(toTileCard));
section.cards!.push(
...includedEntities.slice(0, limit).map(computeFavoriteCardConfig)
);
return section;
}
@@ -110,7 +106,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
return section;
}
section.cards!.push(...entities.map(toTileCard));
section.cards!.push(...entities.map(computeFavoriteCardConfig));
return section;
}
}
@@ -18,11 +18,9 @@ import type {
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type {
LogbookCardConfig,
TileCardConfig,
} from "../../lovelace/cards/types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
@@ -186,15 +184,7 @@ export class SecurityViewStrategy extends ReactiveElement {
),
heading_style: "title",
},
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
...favoriteEntities.map(computeFavoriteCardConfig),
],
});
}
+15 -5
View File
@@ -39,7 +39,7 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
target &&
(target.tagName === "SCRIPT" || target.tagName === "LINK")
) {
recoverFromStaleBuild(target.src || target.href, this);
void recoverFromStaleBuild(target.src || target.href, this);
}
},
true
@@ -47,9 +47,16 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
window.addEventListener("error", async (ev) => {
// A stale build can surface as a runtime error while evaluating a
// freshly (re)loaded chunk; recover rather than log it.
if (recoverFromStaleBuild(ev.error?.message || ev.message, this)) {
const recovery = recoverFromStaleBuild(
ev.error?.message || ev.message,
this
);
if (recovery) {
ev.preventDefault();
return;
// A chunk that is still on the server failed in transport: log it.
if (await recovery) {
return;
}
}
if (!this.hass?.connected) {
return;
@@ -94,9 +101,12 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
: typeof reason === "string"
? reason
: "";
if (recoverFromStaleBuild(reasonMessage, this)) {
const recovery = recoverFromStaleBuild(reasonMessage, this);
if (recovery) {
ev.preventDefault();
return;
if (await recovery) {
return;
}
}
if (!this.hass?.connected) {
return;
-2
View File
@@ -55,7 +55,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
)
: false),
data: ev.detail.data,
hash: ev.detail.hash,
returnUrl,
},
() => import("../dialogs/more-info/ha-more-info-dialog"),
@@ -66,7 +65,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
createMoreInfoUrl(returnUrl, {
entityId: ev.detail.entityId,
view,
hash: ev.detail.hash,
})
);
}
+37 -32
View File
@@ -802,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}",
@@ -924,10 +926,12 @@
"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.",
@@ -1174,12 +1178,12 @@
"integration": "Integration",
"device": "Device",
"area": "Area",
"entity": "Related entities",
"group": "Part of the following groups",
"scene": "Part of the following scenes",
"script": "Part of the following scripts",
"automation": "Part of the following automations",
"blueprint": "Using blueprint"
"entity": "Entities",
"group": "Groups",
"scene": "Scenes",
"script": "Scripts",
"automation": "Automations",
"blueprint": "Blueprint"
},
"data-table": {
"search": "Search",
@@ -1629,6 +1633,7 @@
"knx": "[%key:ui::panel::config::dashboard::knx::main%]",
"insteon": "[%key:ui::panel::config::dashboard::insteon::main%]",
"voice-assistants": "[%key:ui::panel::config::dashboard::voice_assistants::main%]",
"connectivity": "[%key:ui::panel::config::dashboard::connectivity::main%]",
"ai-tasks": "[%key:ui::panel::config::ai_tasks::caption%]"
}
},
@@ -1680,14 +1685,14 @@
"person": "Edit person"
},
"details": "Details",
"context": "Context",
"entity": "Entity",
"state": "State",
"context": "Context",
"floor": "Floor",
"entity_id": "Entity ID",
"entity_id": "ID",
"copy_value": "Copy {label}: {value}",
"labels": "Labels",
"toggle_yaml_mode": "Toggle YAML mode",
"translated": "Translated",
"raw": "Raw",
"back_to_info": "Back to info",
"info": "Information",
"related": "Related",
@@ -2713,6 +2718,10 @@
"main": "Dashboards",
"secondary": "Organize how you interact with your home"
},
"connectivity": {
"main": "Connectivity",
"secondary": "Communication protocols and radios for your devices"
},
"voice_assistants": {
"main": "Voice assistants",
"secondary": "Manage your voice assistants"
@@ -3120,8 +3129,9 @@
"view_supervisor_logs": "View supervisor logs"
},
"uninstall_dialog": {
"title": "Uninstall {name}?",
"remove_data": "Also remove app data",
"title": "Uninstall app",
"text": "{name} and everything in its private data folder will be permanently deleted, including any databases, credentials, and other internal state it kept there.",
"remove_config": "Also delete the app's configuration folder (if used)",
"uninstall": "Uninstall"
},
"restart_dialog": {
@@ -5055,10 +5065,7 @@
}
},
"cloud": {
"title": "The power of Home Assistant Cloud",
"register": {
"confirm_email": "Confirm email"
}
"title": "The power of Home Assistant Cloud"
},
"local": {
"title": "Installing apps",
@@ -6441,17 +6448,17 @@
"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 reach your Home Assistant from any device, with no port forwarding or VPN.",
"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, stored off-site and encrypted, ready to restore your full system on first boot.",
"feature_voice_control_title": "Native Alexa and Google support",
"feature_voice_control_body": "Control every Home Assistant device through Amazon Alexa and Google Assistant, so the devices and routines you already rely on keep working. Set up in minutes, not hours.",
"feature_voice_quality_title": "Faster speech for your own assistant",
"feature_voice_quality_body": "More languages and higher accuracy for Home Assistant's privacy-first voice assistant.",
"feature_companion_title": "Companion app",
"feature_companion_body": "Reach your home from your phone anywhere, with location and sensor updates flowing back securely and no ports to open.",
"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 funds the Open Home Foundation, keeping Home Assistant independent with no ads or data harvesting.",
"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",
@@ -6498,9 +6505,7 @@
"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.",
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
},
"account": {
+190 -27
View File
@@ -1,4 +1,5 @@
import { mainWindow } from "../common/dom/get_main_window";
import { promiseTimeout } from "../common/util/promise-timeout";
import { fireExternalBusMessage } from "../external_app/external_messaging";
import * as staleBuildPatterns from "./stale-build-patterns.json";
import { showToast } from "./toast";
@@ -8,9 +9,19 @@ import { showToast } from "./toast";
// (src/html/_bootstrap_recovery.html.template), which runs before any bundle
// loads and therefore cannot import from here.
const patterns = ((staleBuildPatterns as any).default ??
staleBuildPatterns) as { hashedEntry: string; moduleError: string };
staleBuildPatterns) as {
hashedEntry: string;
moduleError: string;
anyUrl: string;
};
const HASHED_ENTRY = new RegExp(patterns.hashedEntry, "i");
// The same pattern anchored: a URL is ours only when it *is* an entry path, not
// when it merely contains one (/local/frontend_latest/custom.abc12345.js).
const HASHED_PATH = new RegExp(`^(?:${patterns.hashedEntry})$`, "i");
const MODULE_ERROR = new RegExp(patterns.moduleError, "i");
// Any file reference, to tell "a file we do not ship failed" apart from "the
// browser did not say which file failed".
const ANY_URL = new RegExp(patterns.anyUrl, "i");
const RELOAD_STORAGE_KEY = "haStaleBuildReload";
const RELOAD_COOLDOWN = 60_000;
@@ -27,10 +38,131 @@ let toastShown = false;
* on the server after an upgrade. Accepts either a URL (from a resource
* `error` event) or an error message (from a rejected dynamic `import()`).
*/
export const isStaleBuildError = (urlOrMessage: string | undefined): boolean =>
export const isStaleBuildError = (
urlOrMessage: string | undefined
): urlOrMessage is string =>
!!urlOrMessage &&
(HASHED_ENTRY.test(urlOrMessage) || MODULE_ERROR.test(urlOrMessage));
type ChunkVerdict = "gone" | "present" | "unknown";
// How long a verdict other than "gone" stands, so a burst of failures for the
// same chunk asks the server once.
const PROBE_SUPPRESS = 10_000;
const PROBE_TIMEOUT = 5_000;
// Both keyed by chunk URL: during a deploy some chunks can be gone while others
// are still served, so one chunk's verdict must not answer for another.
const pendingProbes = new Map<string, Promise<boolean>>();
const probedNotGone = new Map<string, number>();
/**
* The file a failure is about: its absolute URL when this build ships it,
* `"foreign"` for anything else (a dashboard resource, an extra_module_url, a
* custom panel), or `undefined` when no file was named.
*/
const failedFile = (urlOrMessage: string): string | "foreign" | undefined => {
const match = ANY_URL.exec(urlOrMessage);
if (!match) {
return undefined;
}
try {
const url = new URL(match[0], location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
} catch (_err) {
// Not a URL after all.
}
return "foreign";
};
// Remember a chunk that was not confirmed gone — still served, or no usable
// answer at all — dropping verdicts that have expired.
const rememberNotGone = (url: string): void => {
const now = Date.now();
probedNotGone.forEach((until, key) => {
if (until <= now) {
probedNotGone.delete(key);
}
});
probedNotGone.set(url, now + PROBE_SUPPRESS);
};
/**
* A hashed file of the running build, to probe when the browser did not name
* the one that failed. They all live or die with the build.
*/
const referenceUrl = (): string | undefined => {
try {
const entries = performance.getEntriesByType("resource");
for (let i = entries.length - 1; i >= 0; i--) {
const { name } = entries[i];
const url = new URL(name, location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
}
} catch (_err) {
// ignore
}
return undefined;
};
/**
* Ask the server whether the chunk is really gone.
*
* A failed module load looks identical whether the file was deleted by an
* upgrade or the request never reached the server, and only the first is helped
* by dropping the caches and reloading on a transient failure that throws
* away the cache that could have served the chunk.
*
* HEAD is never intercepted by the service worker (workbox routes GET only), so
* the answer reflects what the server actually has. A server that refuses HEAD
* outright leaves us with no answer, and no answer means no recovery: a GET
* cannot substitute, because the frontend_latest route is CacheFirst with
* `ignoreSearch`, so a controlling worker can answer it from its own cache.
*/
const askServer = async (
url: string,
signal?: AbortSignal
): Promise<ChunkVerdict> => {
try {
const response = await fetch(url, {
method: "HEAD",
cache: "no-store",
signal,
});
if (response.status === 404 || response.status === 410) {
return "gone";
}
// A 5xx or a proxy error page says nothing about the file being deleted.
return response.ok ? "present" : "unknown";
} catch (_err) {
// Aborted, offline, connection reset: unreachable, not stale.
return "unknown";
}
};
const probeChunk = async (url: string): Promise<ChunkVerdict> => {
const controller =
typeof AbortController === "undefined" ? undefined : new AbortController();
try {
// Timed out rather than relying on the abort alone: without
// AbortController a stalled request would never settle, pinning the probe.
const verdict: ChunkVerdict = await promiseTimeout(
PROBE_TIMEOUT,
askServer(url, controller?.signal)
);
return verdict;
} catch (_err) {
if (controller) {
controller.abort();
}
return "unknown";
}
};
const dropCachesAndReload = async (bust: number): Promise<void> => {
// A service worker serves chunks CacheFirst (with `ignoreSearch`), so a
// cache-busting navigation alone would still be answered from the stale
@@ -66,8 +198,9 @@ const dropCachesAndReload = async (bust: number): Promise<void> => {
* guard blocked it so callers can fall back to logging / an error screen
* instead of silently swallowing a still-failing chunk.
*
* Guarded by a monotonic sessionStorage counter NOT the boot guard's URL
* param, which core.ts strips on every successful connect so a recovery that
* Guarded by the time of the last recovery reload in sessionStorage NOT the
* boot guard's URL param, which core.ts strips on every connect so a recovery
* that
* boots and then immediately re-triggers the same missing chunk (a deep-linked
* panel, more-info from the URL, or a preloaded route) cannot reload-loop.
*/
@@ -76,32 +209,22 @@ export const reloadFresh = (): boolean => {
return false;
}
const now = Date.now();
let attempts = 0;
let last = 0;
let storageOk = true;
try {
const stored = sessionStorage.getItem(RELOAD_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
attempts = Number(parsed.n) || 0;
last = Number(parsed.t) || 0;
}
// A missing or unreadable marker reads as 0, i.e. no recent reload; it is
// replaced below rather than standing in the way of every later recovery.
last = Number(sessionStorage.getItem(RELOAD_STORAGE_KEY)) || 0;
} catch (_err) {
storageOk = false;
}
if (now - last > RELOAD_COOLDOWN) {
attempts = 0;
}
if (attempts >= 1) {
// Already reloaded once recently and it still failed: stop, to avoid a
// reload loop on a genuinely broken (not merely stale) deploy.
if (now - last <= RELOAD_COOLDOWN) {
// Already reloaded within the cooldown and it still failed: stop, to avoid
// a reload loop on a genuinely broken (not merely stale) deploy.
return false;
}
try {
sessionStorage.setItem(
RELOAD_STORAGE_KEY,
JSON.stringify({ n: attempts + 1, t: now })
);
sessionStorage.setItem(RELOAD_STORAGE_KEY, String(now));
} catch (_err) {
storageOk = false;
}
@@ -175,19 +298,26 @@ export const reloadForUpdate = (rootEl?: HTMLElement): boolean => {
* Recover from a failed lazy load caused by a stale build (a content-hashed
* chunk that 404s after an upgrade while the app stayed open).
*
* The chunk is probed first: one that is still on the server failed in
* transport, which a cache-dropping reload cannot fix and would make worse, so
* that case is left to the caller to surface.
*
* On a confirmed stale build:
* - clean: reload onto the current build.
* - dirty (an editor has unsaved changes): show a non-dismissable toast and
* auto-reload once the user saves/discards, so unsaved work is never lost.
*
* Returns `true` when the error was a stale-build error and recovery was
* actually started, so callers can skip their own error UI / logging. Returns
* `false` for a non-stale error, or when the loop guard blocked the reload (so
* the caller still surfaces/logs the failure).
* Returns `false` when the error is not a chunk-load failure, names a file this
* build does not ship, or a recent probe did not find the chunk gone.
* Otherwise a promise shared across a burst resolving `true` when recovery
* started (the caller can skip its own error UI / logging) and `false` for
* everything else: the chunk was still served, the probe was inconclusive (a
* network error, a timeout, a 5xx), or the loop guard blocked the reload.
*/
export const recoverFromStaleBuild = (
urlOrMessage: string | undefined,
rootEl?: HTMLElement
): boolean => {
): false | Promise<boolean> => {
// In dev/demo the entry files are unhashed and rebuild churn can throw
// transient import errors; never auto-reload there.
if (__DEV__ || __DEMO__) {
@@ -196,5 +326,38 @@ export const recoverFromStaleBuild = (
if (!isStaleBuildError(urlOrMessage)) {
return false;
}
return reloadForUpdate(rootEl);
const failed = failedFile(urlOrMessage);
if (failed === "foreign") {
// Those fail on their own schedule and say nothing about the build being
// stale, so reloading over them never ends.
return false;
}
// Nothing named (Safari's message names no file): ask about the build itself.
const url = failed ?? referenceUrl();
if (!url) {
// No evidence at all — never drop caches on a guess.
return false;
}
const now = Date.now();
const suppressed = probedNotGone.get(url);
if (suppressed !== undefined && suppressed > now) {
return false;
}
const pending = pendingProbes.get(url);
if (pending) {
return pending;
}
const probe = probeChunk(url)
.then((verdict) => {
if (verdict !== "gone") {
rememberNotGone(url);
return false;
}
return reloadForUpdate(rootEl);
})
.finally(() => {
pendingProbes.delete(url);
});
pendingProbes.set(url, probe);
return probe;
};
+2 -1
View File
@@ -1,4 +1,5 @@
{
"hashedEntry": "/frontend_(?:latest|es5)/[^/?#]+\\.[0-9a-f]{8,}\\.js",
"moduleError": "dynamically imported module|Importing a module script failed|error loading dynamically imported|ChunkLoadError"
"moduleError": "dynamically imported module|Importing a module script failed|error loading dynamically imported|ChunkLoadError",
"anyUrl": "[a-z]+://[^\\s\"'()]+|/[^\\s\"'()]+\\.(?:m?js|css)\\b"
}
+106
View File
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from "vitest";
import { addLatinLabels } from "../../build-scripts/gulp/map-labels.js";
// The rewrite keys on the exact `text-field` @versatiles/style emits; a bump
// that changed it would silently ship local-only names again.
const layer = (id, layout) => ({ id, type: "symbol", layout });
const STYLE = {
layers: [
layer("label-place-city", { "text-field": ["get", "name"] }),
layer("label-street-primary", {
"symbol-placement": "line",
"text-field": ["get", "name"],
}),
layer("label-motorway-shield", { "text-field": "{ref}" }),
{ id: "water", type: "fill" },
],
};
// Just enough of the expression language for the expressions built here.
const evaluate = (expression, properties) => {
if (!Array.isArray(expression)) {
return expression;
}
const [op, ...args] = expression;
switch (op) {
case "get":
return properties[args[0]];
case "has":
return args[0] in properties;
case "!":
return !evaluate(args[0], properties);
case "<":
return evaluate(args[0], properties) < evaluate(args[1], properties);
case "concat":
return args.map((arg) => evaluate(arg, properties)).join("");
case "format":
return args
.filter((_, i) => i % 2 === 0)
.map((arg) => evaluate(arg, properties))
.join("");
case "case":
for (let i = 0; i < args.length - 1; i += 2) {
if (evaluate(args[i], properties)) {
return evaluate(args[i + 1], properties);
}
}
return evaluate(args[args.length - 1], properties);
default:
throw new Error(`Unexpected operator ${op}`);
}
};
const textField = (style, id) =>
style.layers.find((l) => l.id === id).layout["text-field"];
describe("addLatinLabels", () => {
const style = addLatinLabels(STYLE);
const city = textField(style, "label-place-city");
const street = textField(style, "label-street-primary");
it("leaves Latin names alone", () => {
expect(evaluate(city, { name: "Köln", name_en: "Cologne" })).toBe("Köln");
expect(evaluate(city, { name: "1er arrondissement" })).toBe(
"1er arrondissement"
);
});
it("adds the English name under a non-Latin one", () => {
expect(evaluate(city, { name: "Москва", name_en: "Moscow" })).toBe(
"Москва\nMoscow"
);
expect(evaluate(city, { name: "بيروت", name_en: "Beirut" })).toBe(
"بيروت\nBeirut"
);
expect(evaluate(city, { name: "東京", name_en: "Tokyo" })).toBe(
"東京\nTokyo"
);
});
it("sets the English line smaller", () => {
expect(city.at(-1)).toEqual(
expect.arrayContaining([["get", "name_en"], { "font-scale": 0.8 }])
);
});
it("falls back to the local name without an English one", () => {
expect(evaluate(city, { name: "בני ברק" })).toBe("בני ברק");
});
it("keeps street labels on one line", () => {
expect(
evaluate(street, { name: "شارع الحمرا", name_en: "Hamra Street" })
).toBe("شارع الحمرا (Hamra Street)");
});
it("does not touch other layers", () => {
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
expect(style.layers[3]).toEqual(STYLE.layers[3]);
});
});
+489
View File
@@ -0,0 +1,489 @@
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 }));
// The token module is driven directly here, so a stale token and the one that
// replaces it can be played out without a WebSocket.
// Stands in for the instance the proxy runs on, which on Cast is not the host
// serving the page.
const INSTANCE_URL = vi.hoisted(() => "https://instance.local");
const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>());
const refreshMapTilesToken = vi.hoisted(() => vi.fn());
vi.mock("../../../src/data/map_tiles", () => ({
MAP_TILES_PATH: "/api/map_tiles",
mapTilesUrl: (path: string) => `${INSTANCE_URL}${path}`,
refreshMapTilesToken,
subscribeMapTilesToken: (listener: (token: string) => void) => {
tokenListeners.add(listener);
return () => tokenListeners.delete(listener);
},
withMapTilesToken: (url: string) => new URL(url, location.href).href,
}));
const emitToken = (token: string) =>
tokenListeners.forEach((listener) => listener(token));
const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin }));
const STYLE = {
version: 8,
sources: {},
layers: [],
sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }],
};
const rasterLayer = {
// Leaflet returns the layer from addTo, and the source chains off it.
addTo: vi.fn(() => rasterLayer),
redraw: vi.fn(),
options: {} as Record<string, unknown>,
};
const leaflet = {
tileLayer: vi.fn(() => rasterLayer),
} as unknown as LeafletModuleType;
const TOKEN = "test-token";
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
const glHandlers: Record<string, (event?: unknown) => void> = {};
const glMap = {
setStyle: vi.fn(),
on: vi.fn((event: string, handler: (event?: unknown) => 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();
tokenListeners.clear();
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, TOKEN);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
// Through core's proxy, which is what identifies Home Assistant upstream -
// a browser can set neither a User-Agent nor a Referer.
// Absolute, because on Cast this page is not served by the instance.
expect(url).toBe(
`${INSTANCE_URL}/api/map_tiles/raster/{z}/{x}/{y}.png?token={token}`
);
// Leaflet substitutes options into the template on every tile request, so
// the token can be refreshed without recreating the layer.
expect(url).toContain("token={token}");
expect(options).toMatchObject({ token: TOKEN });
// OSM serves no raster past 19, so the last level is scaled up instead.
expect(options.maxNativeZoom).toBe(19);
expect(options.maxZoom).toBeGreaterThan(19);
// The vector layer takes its credit from the style's source instead, so the
// raster layer is the only one carrying attribution itself.
expect(options.attribution).toContain("openstreetmap.org/copyright");
});
// The demo ships without a backend, so there is no proxy to fall back to.
it("falls back to upstream raster in the demo, with a referrer", async () => {
vi.stubGlobal("__DEMO__", true);
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, undefined);
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
expect(url).toBe("https://tile.openstreetmap.org/{z}/{x}/{y}.png");
// OSM refuses a browser that sends neither, and the demo page's meta
// policy strips the referrer unless the tiles ask for it back.
expect(options.referrerPolicy).toBe("origin");
});
it("registers the RTL text plugin once, lazily, from our own host", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false, TOKEN);
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
expect(setRTLTextPlugin).toHaveBeenCalledWith(
`${location.origin}/static/map/mapbox-gl-rtl-text.js`,
true
);
});
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
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, TOKEN);
glHandlers.webglcontextlost();
vi.runAllTimers();
baseLayer.setDarkMode(true);
expect(glMap.setStyle).not.toHaveBeenCalled();
});
});
// A refused request leaves the source dead: the TileJSON is fetched once and
// MapLibre never retries it, so the map stays blank until the style is
// applied again.
describe("recovering from a refused token", () => {
it("asks for a new token and re-applies the style once it arrives", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status: 403 } });
expect(refreshMapTilesToken).toHaveBeenCalled();
emitToken("fresh-token");
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
});
it("does not keep asking while the proxy refuses for another reason", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
for (let i = 0; i < 5; i++) {
glHandlers.error({ error: { status: 403 } });
}
expect(refreshMapTilesToken).toHaveBeenCalledOnce();
});
// The style itself always loads - it is a local file - so applying one while
// the token is still stale says nothing about whether requests get through.
it("still recovers when the theme changes before the token arrives", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status: 403 } });
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
emitToken("fresh-token");
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledTimes(2));
});
// During a restart the proxy can be unregistered rather than refusing, and a
// dropped connection reports no status at all. Both leave the source dead.
it.each([[404], [undefined]])(
"also recovers from status %s",
async (status) => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status } });
expect(refreshMapTilesToken).toHaveBeenCalled();
emitToken("fresh-token");
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
}
);
it("ignores errors that are not a refusal", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glHandlers.error({ error: { status: 500 } });
expect(refreshMapTilesToken).not.toHaveBeenCalled();
});
it("leaves a working map alone when the token is merely refreshed", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
emitToken("fresh-token");
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(glMap.setStyle).not.toHaveBeenCalled();
});
it("redraws the raster layer so refused tiles are asked for again", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, TOKEN);
emitToken("fresh-token");
expect(rasterLayer.options.token).toBe("fresh-token");
expect(rasterLayer.redraw).toHaveBeenCalled();
});
});
+7 -22
View File
@@ -247,15 +247,13 @@ describe("todo query params", () => {
});
describe("more-info query params", () => {
it("decodes the entity, view, and named hash params", () => {
it("decodes the entity and view", () => {
const params = decodeMoreInfoUrl(
"?more-info-entity-id=weather.home&more-info-view=info",
"#forecast=hourly"
"?more-info-entity-id=weather.home&more-info-view=info"
);
expect(params.entityId).toBe("weather.home");
expect(params.view).toBe("info");
expect(params.hash.get("forecast")).toBe("hourly");
});
it("ignores invalid views", () => {
@@ -271,22 +269,21 @@ describe("more-info query params", () => {
createMoreInfoUrl("/lovelace/home?theme=dark", {
entityId: "weather.home",
view: "info",
hash: new URLSearchParams({ forecast: "hourly" }),
})
).toBe(
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info"
);
});
it("removes more-info query params but preserves other hash state", () => {
it("removes more-info query params but preserves the hash", () => {
expect(
removeMoreInfoUrl(
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#some-anchor"
)
).toBe("/lovelace/home?theme=dark#forecast=hourly");
).toBe("/lovelace/home?theme=dark#some-anchor");
});
it("preserves an unrelated hash when creating a more-info url without a dialog hash", () => {
it("preserves the hash of the page it links from", () => {
expect(
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
entityId: "light.kitchen",
@@ -296,16 +293,4 @@ describe("more-info query params", () => {
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info#some-anchor"
);
});
it("clears an existing hash when an empty dialog hash is explicitly supplied", () => {
expect(
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
entityId: "light.kitchen",
view: "info",
hash: new URLSearchParams(),
})
).toBe(
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info"
);
});
});
+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"]);
});
});

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