mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 20:07:26 +00:00
Compare commits
39
Commits
map-zone-dialog
...
rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f79dfc91 | ||
|
|
5861c7487c | ||
|
|
9dbfa821d3 | ||
|
|
1821e8fd67 | ||
|
|
c8a3aa1d9a | ||
|
|
6c606a4968 | ||
|
|
f59a4b4996 | ||
|
|
4c08607dfe | ||
|
|
f082816d9c | ||
|
|
b503862853 | ||
|
|
1afa83d244 | ||
|
|
7b93dde3c6 | ||
|
|
f8cb27b7ab | ||
|
|
62f1509be4 | ||
|
|
efcf93191f | ||
|
|
577ededaf4 | ||
|
|
1cd16ad301 | ||
|
|
2c2f733a82 | ||
|
|
22df9c704c | ||
|
|
d73341b2f8 | ||
|
|
907d15e7c1 | ||
|
|
662ac1dc57 | ||
|
|
39e7a05258 | ||
|
|
5cb7de5ac6 | ||
|
|
0c7d3c8483 | ||
|
|
c92e5aa134 | ||
|
|
8f73718a35 | ||
|
|
4bf2acdb43 | ||
|
|
f622e5eeb9 | ||
|
|
595068597f | ||
|
|
c708f35ab6 | ||
|
|
205874c800 | ||
|
|
47ddcbe2e5 | ||
|
|
c43b8bfbe0 | ||
|
|
e349b00b15 | ||
|
|
0bb8bdc98a | ||
|
|
bbc5385843 | ||
|
|
43dc5651d3 | ||
|
|
18dcf7abc0 |
@@ -74,3 +74,4 @@ test/e2e/app/dist/
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
),
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -27,6 +27,7 @@ const ALLOWED_LICENSES = new Set([
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
|
||||
@@ -1,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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <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 };
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
'© <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);
|
||||
};
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -50,9 +50,6 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
|
||||
azimuth: "°",
|
||||
elevation: "°",
|
||||
},
|
||||
vacuum: {
|
||||
battery_level: "%",
|
||||
},
|
||||
valve: {
|
||||
current_position: "%",
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<% } %>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+23
-51
@@ -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%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
+25
-10
@@ -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">
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+6
-10
@@ -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),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user