Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub 16f2f31348 Merge remote-tracking branch 'origin/dev' into condition-state-attribute-no-for
# Conflicts:
#	src/translations/en.json
2026-08-26 08:46:11 +00:00
Bram KragtenandClaude Opus 4.8 8d7ce043d9 Hide "for" on state condition when matching an attribute
The legacy state condition measures the `for` duration against
`last_changed`, which only updates on state changes, not attribute
changes. So `for` together with `attribute` is unreliable and core is
moving to reject it. Omit the `for` field when an attribute is selected,
drop any lingering duration value, and explain why via a helper.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 16:31:06 +02:00
120 changed files with 1450 additions and 6779 deletions
-1
View File
@@ -74,4 +74,3 @@ test/e2e/app/dist/
.serena
test/benchmarks/results/
+6 -11
View File
@@ -4,7 +4,6 @@ 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);
@@ -90,7 +89,7 @@ function copyQrScannerWorker(staticDir) {
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
}
async function copyMapPanel(staticDir) {
function copyMapPanel(staticDir) {
const staticPath = genStaticPath(staticDir);
copyFileDir(
npmPath("leaflet/dist/leaflet.css"),
@@ -104,10 +103,6 @@ async function copyMapPanel(staticDir) {
npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/")
);
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
}
function copyZXingWasm(staticDir) {
@@ -144,7 +139,7 @@ gulp.task("copy-static-app", async () => {
copyMdiIcons(staticDir);
// Panel assets
await copyMapPanel(staticDir);
copyMapPanel(staticDir);
// Qr Scanner assets
copyZXingWasm(staticDir);
@@ -160,7 +155,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);
await copyMapPanel(paths.demo_output_static);
copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
copyLocaleData(paths.demo_output_static);
@@ -173,7 +168,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);
await copyMapPanel(paths.cast_output_static);
copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
copyLocaleData(paths.cast_output_static);
@@ -189,7 +184,7 @@ gulp.task("copy-static-gallery", async () => {
paths.gallery_output_root
);
await copyMapPanel(paths.gallery_output_static);
copyMapPanel(paths.gallery_output_static);
copyFonts(paths.gallery_output_static);
copyTranslations(paths.gallery_output_static);
copyLocaleData(paths.gallery_output_static);
@@ -220,7 +215,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
}
copyPolyfills(paths.e2eTestApp_output_static);
await copyMapPanel(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
-1
View File
@@ -14,7 +14,6 @@ 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";
-85
View File
@@ -1,85 +0,0 @@
// 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";
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(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;
@@ -424,23 +424,6 @@ const SCHEMAS: {
},
},
},
device_class: {
name: "Device Class",
selector: {
device_class: {
domain: "sensor",
},
},
},
device_class_multiple: {
name: "Device Class (Multiple)",
selector: {
device_class: {
domain: "binary_sensor",
multiple: true,
},
},
},
select_custom: {
name: "Select (Custom)",
selector: {
+2 -5
View File
@@ -75,7 +75,6 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.5.0",
@@ -95,7 +94,7 @@
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.50.0",
"cropperjs": "1.6.3",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
@@ -116,7 +115,6 @@
"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",
@@ -172,7 +170,6 @@
"@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",
@@ -206,7 +203,7 @@
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"minify-literals": "2.1.0",
"pinst": "3.0.0",
"prettier": "3.9.6",
"rspack-manifest-plugin": "5.2.2",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.0"
version = "20260729.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
-1
View File
@@ -27,7 +27,6 @@ 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",
+20 -31
View File
@@ -1,27 +1,13 @@
import type { Map } from "leaflet";
import type { MapBaseLayer } from "../map/base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
import type { Map, TileLayer } from "leaflet";
// 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;
darkMode?: boolean;
token?: string;
}
): Promise<LeafletMapSetup> => {
initialView?: { latitude: number; longitude: number; zoom?: number }
): Promise<[Map, LeafletModuleType, TileLayer]> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
}
@@ -31,11 +17,7 @@ export const setupLeafletMap = async (
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
const map = Leaflet.map(mapElement);
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet");
@@ -56,14 +38,21 @@ export const setupLeafletMap = async (
);
}
// 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
);
const tileLayer = createTileLayer(Leaflet).addTo(map);
return { map, leaflet: Leaflet, baseLayer };
return [map, Leaflet, tileLayer];
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
leaflet.Browser.retina ? "@2x.png" : ".png"
}`,
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: "abcd",
minZoom: 0,
maxZoom: 20,
}
);
-294
View File
@@ -1,294 +0,0 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
import type { StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
import {
MAP_TILES_PATH,
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;
// 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}`;
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 20;
// 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 = new URL(style.sprite, location.href).href;
} else if (Array.isArray(style.sprite)) {
style.sprite = style.sprite.map((sprite) => ({
...sprite,
url: new URL(sprite.url, location.href).href,
}));
}
return style;
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
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;
refused = false;
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) => {
if ((event.error as { status?: number } | undefined)?.status !== 403) {
return;
}
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
return;
}
lastRecovery = Date.now();
refused = true;
refreshMapTilesToken();
});
const unsubscribeToken = subscribeMapTilesToken(() => {
if (vector && refused) {
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(RASTER_TILE_URL, {
attribution: OSM_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
// 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 } =
await import("@maplibre/maplibre-gl-leaflet");
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);
};
+2 -85
View File
@@ -80,67 +80,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
return ensureDialogsClosed(timestamp);
};
/**
* Lets a page with unsaved changes (e.g. the automation editor) veto
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
* true when navigation may proceed.
*/
export interface UnsavedChangesGuard {
isDirty(): boolean;
prompt(): Promise<boolean>;
}
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.add(guard);
};
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.delete(guard);
};
let pendingUnsavedPrompt: Promise<boolean> | undefined;
/**
* Counts navigations that changed the history entry, so a navigation held up
* by an unsaved-changes prompt can tell whether a newer one has moved the app
* on in the meantime.
*/
let committedNavigations = 0;
/**
* Asks each dirty guard whether navigation may proceed. Returns true when
* nothing is dirty or every prompt was confirmed. Concurrent navigations
* share one pending prompt instead of stacking dialogs; the dirty check runs
* before joining it, so a navigation triggered from inside a prompt (e.g. by
* its save action) cannot deadlock on its own promise.
*/
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
guard.isDirty()
);
if (!dirtyGuards.length) {
return Promise.resolve(true);
}
if (!pendingUnsavedPrompt) {
pendingUnsavedPrompt = (async () => {
try {
for (const guard of dirtyGuards) {
// eslint-disable-next-line no-await-in-loop
if (!(await guard.prompt())) {
return false;
}
}
return true;
} finally {
pendingUnsavedPrompt = undefined;
}
})();
}
return pendingUnsavedPrompt;
};
const buildHistoryState = (
data: Record<string, unknown> | undefined,
from?: string
@@ -152,7 +91,7 @@ const buildHistoryState = (
return { ...state, from };
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
@@ -188,28 +127,9 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
fireEvent(mainWindow, "location-changed", {
replace,
});
committedNavigations += 1;
return true;
};
export const navigate = async (path: string, options?: NavigateOptions) => {
// Only guard actual departures: navigating to the current path keeps the
// page, and any unsaved state on it, mounted.
if (path !== currentPath()) {
const navigationsBeforePrompt = committedNavigations;
if (!(await ensureUnsavedChangesConfirmed())) {
return false;
}
if (committedNavigations !== navigationsBeforePrompt) {
// Another navigation landed while the prompt was waiting for an answer,
// so this destination is stale. Dropping it keeps a late answer from
// pulling the user back off the page they are on now.
return false;
}
}
return performNavigation(path, options);
};
/**
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
@@ -222,9 +142,6 @@ export const canGoBack = (): boolean =>
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
* Deliberately not guarded against unsaved changes: pages with such a guard
* confirm in their own back handlers, and delete flows leave through here
* after the edited item is already gone.
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -239,5 +156,5 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
await performNavigation(fallbackPath || "/", { replace: true });
await navigate(fallbackPath || "/", { replace: true });
};
@@ -1,37 +0,0 @@
import deepFreeze from "deep-freeze";
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
export const shareInFlightRequest = <T>(
owner: object,
key: string,
fetcher: () => Promise<T>
): Promise<T> => {
let requests = inFlightRequests.get(owner);
if (!requests) {
requests = new Map();
inFlightRequests.set(owner, requests);
}
const ownerRequests = requests;
const existing = ownerRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const request = fetcher()
.then((result) => deepFreeze(result) as T)
.finally(() => {
if (ownerRequests.get(key) !== request) {
return;
}
ownerRequests.delete(key);
if (ownerRequests.size === 0) {
inFlightRequests.delete(owner);
}
});
ownerRequests.set(key, request);
return request;
};
+14 -43
View File
@@ -3,27 +3,6 @@ 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.
@@ -41,29 +20,21 @@ export const sideTooltipPosition: TooltipPositionCallback = (
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
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 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];
};
+18 -79
View File
@@ -10,16 +10,12 @@ interface MeanFrame {
}
interface MinMaxFrame {
// A frame can hold a gap marker before any value lands in it, so the min/max
// slots below only mean something once this is true.
hasValue: boolean;
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
gapPoint: Point | undefined;
}
const SECOND = 1000;
@@ -53,25 +49,6 @@ function snapFrameSize(step: number): number {
return snapped;
}
// y is NaN for a frame seeded by a gap marker, which has no value yet.
function newFrame(
point: Point,
x: number,
y: number,
gapPoint: Point | undefined
): MinMaxFrame {
return {
hasValue: gapPoint === undefined,
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
gapPoint,
};
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -105,10 +82,7 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const rawY = pointData[1] as number | null;
// Number(null) is 0, which would drag the mean towards zero
if (rawY === null) continue;
const y = Number(rawY);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
@@ -146,34 +120,21 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
if (isNaN(x)) continue;
const rawY = pointData[1] as number | null;
if (rawY === null) {
// The chart data modules push a null value to break the line where an
// entity was unavailable. Number(null) is 0, so such a marker must stay
// out of the comparisons below, where it would win the minimum slot
// whenever the readings are positive and discard the frame's real
// minimum. One marker per frame is enough to break the line, and keeping
// them all would blow up the output on series that are mostly null. The
// last one wins: where the break lands only depends on which points it
// sits between, not on its own x.
const gapIndex = Math.floor(x / step);
const gapFrame = frames.get(gapIndex);
if (gapFrame) {
gapFrame.gapPoint = point;
} else {
frames.set(gapIndex, newFrame(point, x, NaN, point));
}
continue;
}
const y = Number(rawY);
if (isNaN(y)) continue;
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, newFrame(point, x, y, undefined));
} else if (frame.hasValue) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
@@ -186,40 +147,18 @@ export function downSampleLineData<
frame.maxX = x;
frame.maxY = y;
}
} else {
// the frame held nothing but a marker so far
frame.hasValue = true;
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
if (frame.hasValue) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
if (frame.gapPoint !== undefined) {
// A marker followed by a value in its own frame is a gap that closed
// within one frame, which is about one device pixel: too narrow to show.
// The kept points are exactly min and max, so comparing against the
// later of the two catches that without any work on the ingest path. A
// marker-only frame compares against its own x and always passes.
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
result.push(frame.gapPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
@@ -328,7 +328,6 @@ export class StateHistoryChartLine extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
onFractionDigits: (digits) => {
if (digits !== this._yAxisFractionDigits) {
this._yAxisFractionDigits = digits;
@@ -1,3 +1,4 @@
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";
@@ -12,7 +13,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 { itemTooltipPosition } from "./chart-tooltip-position";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -23,6 +24,7 @@ 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")
@@ -41,6 +43,10 @@ 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;
@@ -63,6 +69,13 @@ 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() {
@@ -70,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -180,13 +193,19 @@ 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")
changedProps.has("_yWidth") ||
widthChanged
) {
this._createOptions();
}
@@ -196,14 +215,22 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? 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",
@@ -227,41 +254,56 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
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,
});
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,
}
return label;
},
hideOverlap: true,
},
: {
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,
},
},
grid: {
top: 10,
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
},
tooltip: {
renderMode: "html",
position: itemTooltipPosition,
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -401,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,6 +79,10 @@ 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;
@@ -227,6 +231,7 @@ 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}
@@ -424,6 +429,12 @@ 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;
}
-1
View File
@@ -446,7 +446,6 @@ export class StatisticsChart extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
// Bar charts stay anchored at 0, so precision must reflect the
// 0-based range that is actually rendered.
includeZero: !yAxisScale,
+26 -121
View File
@@ -1,32 +1,13 @@
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
import { getPrecision, nice, round } from "echarts/lib/util/number";
// A range smaller than this fraction of the axis magnitude is floating-point
// noise (e.g. from summed statistics), not real precision.
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
// precision derived here cannot drift from the ticks it renders.
const SPLIT_NUMBER = 5;
// How thin a gap between the data and the plot edge counts as no gap at all,
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
// maximum to a tick multiple, which usually leaves headroom, but quantized
// states often land exactly on a tick and get none — collapsing area-filled
// series, which are drawn from their value down to the axis minimum. Widening
// the extent by this much before that rounding bumps any axis with less
// headroom out to a full tick, and leaves the rest where they are.
const GAP_FRACTION_OF_SPAN = 0.02;
// A percentage has a real ceiling the way zero is a real floor, so the gap must
// not push the axis past it. Not every `%` sensor is bounded — power factor is
// signed and can read over 100 — so this only applies while the data stays under.
const PERCENT_MAX = 100;
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range, by asking ECharts for the same tick interval it will
// render. This matches the precision it actually draws, so labels are neither
// truncated to identical values nor padded with extra zeros.
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(
min: number,
max: number,
@@ -41,7 +22,13 @@ export function computeYAxisFractionDigits(
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
return getPrecision(nice(range / SPLIT_NUMBER, true));
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
// Rounding the mantissa to a nice value only ever carries to the next power
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
return Math.max(0, -niceExponent);
}
interface YAxisExtentValues {
@@ -57,116 +44,34 @@ const resolveYAxisBound = (
values: YAxisExtentValues
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
// A constant series has no span for the gap to be a fraction of, so ECharts
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
// expansion a flat series relies on for its window. Run that expansion here and
// return fixed bounds, which also suppresses the gap.
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
const precision = getPrecision(interval);
return {
min: round(Math.floor(lo / interval) * interval, precision),
max: round(Math.ceil(hi / interval) * interval, precision),
};
};
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
// callbacks with the extent of the visible (zoom-filtered) data on every
// dataZoom, and always before the label formatter runs, so the fraction digits
// recomputed here track the zoomed range. A callback returns `undefined`
// wherever auto-scaling should stand, and a number only where the axis has to be
// pinned: an explicit bound, the zero anchor, or a constant series.
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
// always before the label formatter runs, so recomputing the fraction digits
// here keeps zoomed-in labels distinct. The callbacks return the original
// bounds unchanged, so auto-scaling still applies when a bound is not set.
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
// Such an axis also gets no gap: pushing it below zero would defeat the zero
// anchoring and leave the bars floating above the axis.
includeZero?: boolean;
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
// a limit the data itself never crosses.
unit?: string;
onFractionDigits: (digits: number) => void;
}): {
min: (values: YAxisExtentValues) => number | undefined;
max: (values: YAxisExtentValues) => number | undefined;
boundaryGap: [number, number];
splitNumber: number;
} {
const { min, max, includeZero, unit, onFractionDigits } = options;
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
const resolveBounds = (values: YAxisExtentValues) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
if (
includeZero ||
!Number.isFinite(values.min) ||
!Number.isFinite(values.max)
) {
return { min: resolvedMin, max: resolvedMax, gap: 0 };
}
if (values.min === values.max) {
const flat = flatSeriesExtent(values.min, [
resolvedMin !== undefined,
resolvedMax !== undefined,
]);
// The expansion is a fraction of the magnitude, so a constant series near
// the ceiling would otherwise overshoot it too.
const flatMax =
naturalMax !== undefined && values.max <= naturalMax
? Math.min(flat.max, naturalMax)
: flat.max;
return {
min: resolvedMin ?? flat.min,
max: resolvedMax ?? flatMax,
gap: 0,
};
}
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
// Never let the gap carry a series past a boundary it does not itself cross.
const floor = values.min >= 0 ? 0 : undefined;
const ceiling =
naturalMax !== undefined && values.max <= naturalMax
? naturalMax
: values.max <= 0
? 0
: undefined;
return {
min:
resolvedMin ??
(floor !== undefined && values.min - floor < gap ? floor : undefined),
max:
resolvedMax ??
(ceiling !== undefined && ceiling - values.max < gap
? ceiling
: undefined),
gap,
};
};
const { min, max, includeZero, onFractionDigits } = options;
return {
// Always emit the key. `setOption` merges the Y axis rather than replacing
// it, so a conditionally spread gap would survive a chart switching to a
// zero-anchored type and leave its bars floating.
boundaryGap: includeZero
? [0, 0]
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
splitNumber: SPLIT_NUMBER,
min: (values) => {
const bounds = resolveBounds(values);
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
const extentMin = resolvedMin ?? values.min;
const extentMax = resolvedMax ?? values.max;
onFractionDigits(
computeYAxisFractionDigits(
bounds.min ?? values.min - bounds.gap,
bounds.max ?? values.max + bounds.gap,
includeZero
)
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
);
return bounds.min;
return resolvedMin;
},
max: (values) => resolveBounds(values).max,
max: (values) => resolveYAxisBound(max, values),
};
}
@@ -12,6 +12,7 @@ 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";
@@ -179,15 +180,12 @@ export abstract class HaDeviceAutomationPicker<
(a, idx) => value === `${a.device_id}_${idx}`
);
const described =
automation ?? (this.value?.domain ? this.value : undefined);
const text = described
const text = automation
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
described
automation
)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
@@ -197,24 +195,29 @@ 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
).catch(() => [] as T[])
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
).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.
// 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 (!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(
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
);
}
this._renderEmpty = true;
+47 -71
View File
@@ -1,4 +1,4 @@
import { mdiSwapHorizontal } from "@mdi/js";
import { mdiAlertOutline } 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,7 +22,6 @@ 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";
@@ -105,14 +104,6 @@ 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> = {};
@@ -196,8 +187,7 @@ export class HaDevicePicker extends LitElement {
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[],
replacementDeviceIds: string[] | undefined
items: (DevicePickerItem | string)[]
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
@@ -213,11 +203,7 @@ 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) &&
(!replacementDeviceIds || replacementDeviceIds.includes(id))
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
return { candidates, primaryId: split.primary_id };
}
);
@@ -264,26 +250,26 @@ export class HaDevicePicker extends LitElement {
};
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
// 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) {
// 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) {
return html`
<ha-svg-icon
slot="start"
.path=${mdiSwapHorizontal}
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
></ha-svg-icon>
<span slot="headline"
>${this.hass.localize(
"ui.components.device-picker.device_replaced"
)}</span
>
<span slot="headline">${replacementName}</span>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
@@ -413,23 +399,31 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems(),
this.replacementDeviceIds
this._getItems()
)
: 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,
canReplace
replacementName
);
return html`
<ha-generic-picker
.noUnknownState=${canReplace}
.hass=${this.hass}
.autofocus=${this.autofocus}
.disabled=${this.disabled}
@@ -449,9 +443,14 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
.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")
}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -465,52 +464,30 @@ export class HaDevicePicker extends LitElement {
}) {
const { candidates } = replacement;
// 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;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName && replacementDomain
replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one_integration",
{
device: replacementName,
integration: domainToName(
this.hass.localize,
replacementDomain
),
}
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
: 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}
>
${
candidates.length === 1
? this.hass.localize("ui.components.device-picker.replace_update")
: this.hass.localize("ui.components.device-picker.replace_choose")
}
${this.hass.localize("ui.components.device-picker.replace_device")}
</ha-button>
</ha-alert>
`;
@@ -521,8 +498,7 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems(),
this.replacementDeviceIds
this._getItems()
);
if (!replacement?.candidates.length) {
return;
+1 -1
View File
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
getStatisticIds,
getStatisticLabel,
type StatisticsMetaData,
} from "../../data/recorder";
import { getStatisticIds } from "../../data/recorder_statistic_ids";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item";
+1 -1
View File
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
static styles = css`
.issue-type {
position: relative;
padding: var(--ha-alert-padding, 8px);
padding: 8px;
display: flex;
}
.icon {
-216
View File
@@ -1,216 +0,0 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
import { computeDeviceClassName } from "../data/entity/device_class";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
export const getDeviceClassOptions = (
domain: string,
localize: LocalizeFunc
): PickerComboBoxItem[] =>
(DOMAIN_DEVICE_CLASSES[domain] ?? []).map((deviceClass) => {
const primary = computeDeviceClassName(localize, domain, deviceClass);
return { id: deviceClass, primary, sorting_label: primary };
});
@customElement("ha-device-class-picker")
export class HaDeviceClassPicker extends LitElement {
@property() public domain?: string;
@property({ attribute: false }) public value?: string | string[];
@property({ type: Boolean }) public multiple = false;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
private _loadedDomain?: string;
protected willUpdate() {
if (!this.domain || !this._i18n || this._loadedDomain === this.domain) {
return;
}
this._loadedDomain = this.domain;
this._i18n.loadBackendTranslation("entity_component", this.domain);
}
private get _value(): string[] {
return this.value ? ensureArray(this.value) : [];
}
private _deviceClassName(deviceClass: string): string {
return this._i18n && this.domain
? computeDeviceClassName(this._i18n.localize, this.domain, deviceClass)
: deviceClass;
}
private _options = memoizeOne(
(
domain: string | undefined,
localize: LocalizeFunc | undefined
): PickerComboBoxItem[] =>
domain && localize ? getDeviceClassOptions(domain, localize) : []
);
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(this.domain, this._i18n?.localize);
return this.multiple
? this._availableOptions(options, this._value)
: options;
};
private _valueRenderer = (value: string) =>
html`<span slot="headline">${this._deviceClassName(value)}</span>`;
private _notFoundLabel = (search: string) => {
const term = html`<b>'${search}'</b>`;
return this._i18n
? this._i18n.localize("ui.components.device-class-picker.no_match", {
term,
})
: html`No device classes found for ${term}`;
};
protected render() {
const localize = this._i18n?.localize;
const emptyLabel = localize?.(
"ui.components.device-class-picker.no_device_classes"
);
if (this.multiple) {
const value = this._value;
return html`
${
value.length
? html`
<ha-chip-set>
${repeat(
value,
(deviceClass) => deviceClass,
(deviceClass) => {
const label = this._deviceClassName(deviceClass);
return html`
<ha-input-chip
.item=${deviceClass}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
<ha-generic-picker
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.addButtonLabel=${
this.label ?? localize?.("ui.components.device-class-picker.add")
}
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
}
return html`
<ha-generic-picker
.label=${
this.label ??
localize?.("ui.components.device-class-picker.device_class")
}
.value=${this.value as string | undefined}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || undefined });
}
private _itemAdded(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
const deviceClass = ev.detail.value;
if (!deviceClass || this._value.includes(deviceClass)) {
return;
}
this._setValue([...this._value, deviceClass]);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const deviceClass = (ev.currentTarget as HTMLElement & { item: string })
.item;
this._setValue(this._value.filter((item) => item !== deviceClass));
}
private _setValue(value: string[]) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
static styles = css`
:host {
display: block;
}
ha-generic-picker {
display: block;
width: 100%;
}
ha-chip-set {
padding: 8px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-class-picker": HaDeviceClassPicker;
}
}
+1 -10
View File
@@ -111,11 +111,6 @@ 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;
@@ -153,10 +148,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState")
) {
if (changedProperties.has("value")) {
this._setUnknownValue();
}
}
@@ -295,7 +287,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
this.value === null ||
@@ -1,45 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DeviceClassSelector } from "../../data/selector";
import "../ha-device-class-picker";
@customElement("ha-selector-device_class")
export class HaDeviceClassSelector extends LitElement {
@property({ attribute: false }) public selector!: DeviceClassSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-device-class-picker
.domain=${this.selector.device_class?.domain}
.value=${this.value}
.multiple=${this.selector.device_class?.multiple ?? false}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-device-class-picker>
`;
}
static styles = css`
ha-device-class-picker {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-device_class": HaDeviceClassSelector;
}
}
@@ -30,7 +30,6 @@ const LOAD_ELEMENTS = {
date: () => import("./ha-selector-date"),
datetime: () => import("./ha-selector-datetime"),
device: () => import("./ha-selector-device"),
device_class: () => import("./ha-selector-device-class"),
duration: () => import("./ha-selector-duration"),
entity: () => import("./ha-selector-entity"),
entity_name: () => import("./ha-selector-entity-name"),
+2 -41
View File
@@ -25,11 +25,9 @@ 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 {
@@ -162,8 +160,6 @@ export class HaMap extends ReactiveElement {
private Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _resizeObserver?: ResizeObserver;
private _mapItems: (Marker | Circle)[] = [];
@@ -215,7 +211,6 @@ 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
@@ -313,7 +308,6 @@ 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;
@@ -328,31 +322,11 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
// 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, {
[this.leafletMap, this.Leaflet] = 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) {
@@ -917,22 +891,9 @@ export class HaMap extends ReactiveElement {
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
/* Only the raster fallback is inverted for dark mode, the vector style
ships its own dark cartography. */
.leaflet-tile-pane .leaflet-tile {
.leaflet-tile-pane {
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,7 +7,6 @@ import {
mdiHome,
mdiLabel,
mdiMinusBox,
mdiSwapHorizontal,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -178,51 +177,36 @@ export class HaTargetPickerItemRow extends LitElement {
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: 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
: 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
? this.hass.localize(
"ui.components.target-picker.device_replaced_headline"
)
: name
}</span
>
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? canMigrate
? 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.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
@@ -272,7 +256,7 @@ export class HaTargetPickerItemRow extends LitElement {
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_update"
"ui.components.target-picker.replace_device"
)}
</ha-button>
`
@@ -564,73 +548,75 @@ export class HaTargetPickerItemRow extends LitElement {
this.primaryEntitiesOnly
);
let referencedAreas = entries.referenced_areas;
const hiddenAreaIds: string[] = [];
if (this.type === "floor" || this.type === "label") {
referencedAreas = referencedAreas.filter((area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
});
);
}
let referencedDevices = entries.referenced_devices;
const hiddenDeviceIds: string[] = [];
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
referencedDevices = referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
});
);
}
const referencedEntities = entries.referenced_entities.filter(
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
@@ -645,9 +631,9 @@ export class HaTargetPickerItemRow extends LitElement {
(this.type === "area" && entity.area_id === this.itemId) ||
(this.type === "floor" &&
entity.area_id &&
referencedAreas.includes(entity.area_id)) ||
entries.referenced_areas.includes(entity.area_id)) ||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
referencedDevices.includes(entity.device_id || "")
entries.referenced_devices.includes(entity.device_id || "")
) {
return entityRegMeetsFilter(
entity,
@@ -662,12 +648,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
);
this._entries = {
...entries,
referenced_areas: referencedAreas,
referenced_devices: referencedDevices,
referenced_entities: referencedEntities,
};
this._entries = entries;
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to extract target", e);
-34
View File
@@ -480,40 +480,6 @@ 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 -38
View File
@@ -3,16 +3,10 @@ import type { HomeAssistant } from "../types";
type StrictConnectionMode = "disabled" | "guard_page" | "drop_connection";
export interface CloudAutoLogin {
email: string;
failed: string | null;
}
interface CloudStatusNotLoggedIn {
logged_in: false;
cloud: "disconnected" | "connecting" | "connected";
http_use_ssl: boolean;
auto_login: CloudAutoLogin | null;
}
export interface CertificateInformation {
@@ -108,15 +102,15 @@ export interface CloudLoginMFA extends CloudLoginBase {
code: string;
}
export type CloudEvent =
| { type: "login" | "logout" | "auto_login_cancelled" }
| { type: "auto_login_failed"; translation_key: string };
export const cloudLogin = ({
hass,
...rest
}: CloudLoginPassword | CloudLoginMFA) =>
hass.callApi<{ success: boolean }>("POST", "cloud/login", rest);
hass.callApi<{ success: boolean; cloud_pipeline?: string }>(
"POST",
"cloud/login",
rest
);
export const cloudLogout = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/logout");
@@ -136,33 +130,6 @@ export const cloudRegister = (
password,
});
export const cloudRegisterAutoLogin = (
hass: HomeAssistant,
email: string,
password: string
) =>
hass.callApi("POST", "cloud/register_auto_login", {
email,
password,
});
export const attemptCloudAutoLoginNow = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/attempt_auto_login_now" });
export const resendCloudAutoLoginConfirm = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/resend_auto_login_confirm" });
export const cancelCloudAutoLogin = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/cancel_auto_login" });
export const subscribeCloudEvents = (
hass: HomeAssistant,
callback: (event: CloudEvent) => void
) =>
hass.connection.subscribeMessage<CloudEvent>(callback, {
type: "cloud/subscribe_events",
});
export const cloudResendVerification = (hass: HomeAssistant, email: string) =>
hass.callApi("POST", "cloud/resend_confirm", {
email,
+15 -58
View File
@@ -182,69 +182,26 @@ export const deviceAutomationEditorMode = (
: "unknown-device";
};
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[],
// 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 = (
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (!a.entity_id && !b.entity_id) {
return true;
}
if (!a.entity_id || !b.entity_id) {
if (typeof a !== typeof b) {
return false;
}
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
)
);
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]);
});
};
const compareEntityIdWithEntityRegId = (
+28 -35
View File
@@ -1909,13 +1909,20 @@ export const downloadEnergyData = (
const device_consumption_water = energyData.prefs.device_consumption_water;
const stats = energyData.state.stats;
interface CsvRow {
id: string;
type: string;
unit: string;
data: StatisticValue[];
}
const rows: CsvRow[] = [];
const timeSet = new Set<number>();
Object.values(stats).forEach((stat) => {
stat.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const headers =
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n";
const csv: string[] = [];
csv[0] = headers;
const processCsvRow = function (
id: string,
@@ -1923,7 +1930,20 @@ export const downloadEnergyData = (
unit: string,
data: StatisticValue[]
) {
rows.push({ id, type, unit, data });
let n = 0;
const row: string[] = [];
row.push(id);
row.push(type);
row.push(unit.normalize("NFKD"));
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
};
const processStat = function (stat: string, type: string, unit: string) {
@@ -2170,33 +2190,6 @@ export const downloadEnergyData = (
);
}
const timeSet = new Set<number>();
rows.forEach((row) => {
row.data.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const csv: string[] = [
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n",
];
rows.forEach(({ id, type, unit, data }) => {
let n = 0;
const row: string[] = [id, type, unit.normalize("NFKD")];
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
});
const blob = new Blob(csv, {
type: "text/csv",
});
+64
View File
@@ -1,7 +1,71 @@
import type { LocalizeFunc } from "../../common/translations/localize";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../sensor_entity_constants";
export const NO_DEVICE_CLASS = "none";
// Mirrors core's `<Domain>DeviceClass` enums, until the backend exposes them.
export const DOMAIN_DEVICE_CLASSES: Record<string, readonly string[]> = {
binary_sensor: [
"battery",
"battery_charging",
"carbon_monoxide",
"cold",
"connectivity",
"door",
"garage_door",
"gas",
"heat",
"light",
"lock",
"moisture",
"motion",
"moving",
"occupancy",
"opening",
"plug",
"power",
"presence",
"problem",
"running",
"safety",
"smoke",
"sound",
"tamper",
"update",
"vibration",
"window",
],
button: ["identify", "restart", "update"],
cover: [
"awning",
"blind",
"curtain",
"damper",
"door",
"garage",
"gate",
"shade",
"shutter",
"window",
],
event: ["button", "doorbell", "motion"],
humidifier: ["dehumidifier", "humidifier"],
image_processing: ["alpr", "face", "ocr"],
infrared: ["emitter", "receiver"],
media_player: ["projector", "receiver", "speaker", "tv"],
number: SENSOR_NUMERIC_DEVICE_CLASSES,
sensor: [
...SENSOR_NUMERIC_DEVICE_CLASSES,
"date",
"enum",
"timestamp",
"uptime",
],
switch: ["outlet", "switch"],
update: ["firmware"],
valve: ["gas", "water"],
};
export const computeDeviceClassName = (
localize: LocalizeFunc,
domain: string,
+1 -2
View File
@@ -1,7 +1,6 @@
import { computeDomain } from "../../common/entity/compute_domain";
import type { HomeAssistant } from "../../types";
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
import { NO_DEVICE_CLASS } from "./device_class";
import { DOMAIN_DEVICE_CLASSES, NO_DEVICE_CLASS } from "./device_class";
const SEPARATOR = "/";
-136
View File
@@ -1,136 +0,0 @@
import type { Connection } from "home-assistant-js-websocket";
import { waitForMs } from "../common/util/wait";
export const MAP_TILES_PATH = "/api/map_tiles";
// Well inside the token's lifetime, as util/brands-url.ts does.
const TOKEN_REFRESH_MS = 30 * 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 refreshInterval: ReturnType<typeof setInterval> | undefined;
let watchingConnection = false;
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> => {
activeConnection = connection;
if (!token) {
await attempt(connection, BLOCKING_DELAYS_MS);
}
if (!token) {
attempt(connection, BACKGROUND_DELAYS_MS).then(() =>
scheduleRefresh(connection)
);
return undefined;
}
scheduleRefresh(connection);
return token;
};
const scheduleRefresh = (connection: Connection) => {
if (token && !refreshInterval) {
refreshInterval = setInterval(() => {
fetchToken(connection).catch(() => {
// Keep the current token; the next interval retries.
});
}, TOKEN_REFRESH_MS);
}
if (!watchingConnection) {
watchingConnection = true;
// 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.
connection.addEventListener("ready", () => {
fetchToken(connection).catch(() => {
// Nothing to do; the interval keeps trying.
});
});
}
};
/**
* 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);
};
/**
* 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, location.href);
} catch {
return url;
}
if (token && parsed.pathname.startsWith(`${MAP_TILES_PATH}/`)) {
parsed.searchParams.set("token", token);
}
return parsed.href;
};
+1 -19
View File
@@ -1,4 +1,3 @@
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
@@ -8,7 +7,7 @@ export interface ResolvedMediaSource {
}
export const resolveMediaSource = (
hass: Pick<HomeAssistant, "callWS">,
hass: HomeAssistant,
media_content_id: string
) =>
hass.callWS<ResolvedMediaSource>({
@@ -16,23 +15,6 @@ export const resolveMediaSource = (
media_content_id,
});
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
// core). Resolving again returns a different signature, which would defeat the
// browser cache, so reuse the resolved URL for just under its validity.
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
export const resolveMediaSourceWithCache = (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
media_content_id: string
): Promise<ResolvedMediaSource> =>
timeCacheEntityPromiseFunc(
"_resolvedMediaSource",
RESOLVE_CACHE_TIME,
resolveMediaSource,
hass,
media_content_id
);
export const browseLocalMediaPlayer = (
hass: HomeAssistant,
mediaContentId?: string
+4 -2
View File
@@ -40,7 +40,8 @@ export const searchPlaces = (
limit ? `&limit=${limit}` : ""
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
hass.locale.language
}&email=abuse@home-assistant.io`
}&email=abuse@home-assistant.io`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
@@ -58,7 +59,8 @@ export const reverseGeocode = (
location[1]
}&accept-language=${hass.locale.language}&zoom=${
zoom ?? 18
}&format=jsonv2&email=abuse@home-assistant.io`
}&format=jsonv2&email=abuse@home-assistant.io`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
+9
View File
@@ -160,6 +160,15 @@ export const getRecorderEntityOptions = (
entity_id,
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
statistic_type?: "mean" | "sum"
) =>
hass.callWS<StatisticsMetaData[]>({
type: "recorder/list_statistic_ids",
statistic_type,
});
export const getStatisticMetadata = (
hass: HomeAssistant,
statistic_ids?: string[]
-21
View File
@@ -1,21 +0,0 @@
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
import type { HomeAssistant } from "../types";
import type { StatisticsMetaData } from "./recorder";
type StatisticIdsType = "mean" | "sum";
type StatisticIdsApi = Pick<HomeAssistant, "callWS">;
export const getStatisticIds = (
hass: StatisticIdsApi,
statistic_type?: StatisticIdsType
) =>
shareInFlightRequest(
hass.callWS,
`recorder/list_statistic_ids:${statistic_type ?? "all"}`,
() =>
hass.callWS<StatisticsMetaData[]>({
type: "recorder/list_statistic_ids",
statistic_type,
})
);
-8
View File
@@ -48,7 +48,6 @@ export type Selector =
| DeviceSelector
| FloorSelector
| LegacyDeviceSelector
| DeviceClassSelector
| DurationSelector
| EntitySelector
| EntityNameSelector
@@ -485,13 +484,6 @@ export interface SelectSelector {
} | null;
}
export interface DeviceClassSelector {
device_class: {
domain: string;
multiple?: boolean;
} | null;
}
export interface SelectorSelector {
selector: {} | null;
}
+4 -10
View File
@@ -14,7 +14,6 @@ import {
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
export const TARGET_SEPARATOR = "________";
@@ -55,18 +54,13 @@ export const extractFromTarget = async (
target: HassServiceTarget,
expandGroup = false,
primaryEntitiesOnly = true
) => {
const request = {
type: "extract_from_target" as const,
) =>
callWS<ExtractFromTargetResult>({
type: "extract_from_target",
target,
expand_group: expandGroup,
primary_entities_only: primaryEntitiesOnly,
};
return shareInFlightRequest(callWS, JSON.stringify(request), () =>
callWS<ExtractFromTargetResult>(request)
);
};
});
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
const tempTarget = {
-10
View File
@@ -526,16 +526,6 @@ export const fetchZwaveNetworkStatus = (
});
};
/** Node IDs of the nodes each node can reach directly, keyed by node ID. */
export const fetchZwaveNetworkNeighbors = (
hass: HomeAssistant,
entry_id: string
): Promise<Record<number, number[]>> =>
hass.callWS({
type: "zwave_js/network_neighbors",
entry_id,
});
export const fetchZwaveDataCollectionStatus = (
hass: HomeAssistant,
entry_id: string
+3 -8
View File
@@ -230,13 +230,13 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (!this.isConnected || !this._open || this._params !== expectedParams) {
if (!this._open || this._params !== expectedParams) {
return;
}
await this._waitForSelectorElements();
if (!this.isConnected || !this._open || this._params !== expectedParams) {
if (!this._open || this._params !== expectedParams) {
return;
}
@@ -250,12 +250,7 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (
!this.isConnected ||
!this._open ||
this._params !== expectedParams ||
!this._dialog
) {
if (!this._open || this._params !== expectedParams || !this._dialog) {
return;
}
+54 -208
View File
@@ -1,11 +1,9 @@
import { mdiCheck, mdiContentCopy, mdiDevices, mdiTextureBox } from "@mdi/js";
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
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 type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
@@ -13,16 +11,8 @@ import { getEntityContext } from "../../common/entity/context/get_entity_context
import checkValidDate from "../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import "../../components/ha-attribute-value";
import "../../components/ha-floor-icon";
import "../../components/ha-icon";
import "../../components/ha-icon-next";
import "../../components/ha-label";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import type { LocalizeKeys } from "../../common/translations/localize";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -35,8 +25,6 @@ import { getFeatures } from "../../common/entity/get_domain_features";
import { supportsFeature } from "../../common/entity/supports-feature";
import { titleCase } from "../../common/string/title-case";
import { stringCompare } from "../../common/string/compare";
import { brandsUrl } from "../../util/brands-url";
import { showToast } from "../../util/toast";
interface DetailsViewParams {
entityId: string;
@@ -45,10 +33,7 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
displayValue?: TemplateResult;
href?: string;
icon?: TemplateResult;
copyable?: boolean;
}
@customElement("ha-more-info-details")
@@ -67,17 +52,6 @@ class HaMoreInfoDetails extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _copiedValue?: string;
private _copyFeedbackTimeout?: number;
public disconnectedCallback(): void {
super.disconnectedCallback();
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = undefined;
this._copiedValue = undefined;
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("entry") && this.entry) {
@@ -119,12 +93,12 @@ class HaMoreInfoDetails extends LitElement {
? 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 labelNames = labels.map(({ id, entry }) => entry?.name ?? id);
const labelNames =
this.entry?.labels.map(
(labelId) =>
this._labels?.find((label) => label.label_id === labelId)?.name ??
labelId
) ?? [];
const contextEntries: DetailEntry[] = [];
if (floor && floorName) {
@@ -132,7 +106,6 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.dialogs.more_info_control.floor",
value: floorName,
href: "/config/areas/dashboard",
icon: html`<ha-floor-icon slot="end" .floor=${floor}></ha-floor-icon>`,
});
}
if (area && areaName) {
@@ -140,9 +113,6 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.area",
value: areaName,
href: `/config/areas/area/${area.area_id}`,
icon: area.icon
? html`<ha-icon slot="end" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
});
}
if (device && deviceName) {
@@ -150,7 +120,6 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.device",
value: deviceName,
href: `/config/devices/device/${device.id}`,
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
});
}
if (this.entry?.platform && integrationName) {
@@ -160,63 +129,31 @@ class HaMoreInfoDetails extends LitElement {
href: this.entry.config_entry_id
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
: undefined,
icon: html`<img
slot="end"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: this.entry.platform,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`,
});
}
contextEntries.push(
const entityEntries: DetailEntry[] = [
{
translationKey: "ui.dialogs.more_info_control.entity_id",
value: this.params.entityId,
copyable: true,
},
{
translationKey: "ui.dialogs.more_info_control.labels",
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
displayValue: labels.length
? html`<div class="labels">
${labels.map(
({ id, entry }) => html`
<ha-label
class="text-ellipsis"
.color=${entry?.color ?? undefined}
.description=${entry?.description ?? undefined}
>
${
entry?.icon
? html`<ha-icon
slot="icon"
.icon=${entry.icon}
></ha-icon>`
: nothing
}
${entry?.name ?? id}
</ha-label>
`
)}
</div>`
: undefined,
}
);
},
];
const yamlData = {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
...(contextEntries.length
? {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
},
}
: {}),
entity: {
entity_id: this.params.entityId,
labels: labelNames,
},
@@ -234,9 +171,17 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
<ha-grouped-list>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
${
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(
@@ -246,6 +191,14 @@ class HaMoreInfoDetails extends LitElement {
${this._renderEntries(stateEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.entity"
)}
>
${this._renderEntries(entityEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
@@ -331,72 +284,17 @@ class HaMoreInfoDetails extends LitElement {
}
private _renderEntries(entries: DetailEntry[]) {
return entries.map((entry) => {
const label = this.hass.localize(entry.translationKey);
if (!entry.href && !entry.copyable) {
return html`
<ha-list-item-value .label=${label}
>${entry.displayValue ?? entry.value}</ha-list-item-value
>
`;
}
if (entry.copyable) {
return html`
<ha-list-item-button
aria-label=${this.hass.localize(
"ui.dialogs.more_info_control.copy_value",
{ label, value: entry.value }
)}
data-value=${entry.value}
@click=${this._copyValue}
>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
<ha-svg-icon
class=${this._copiedValue === entry.value ? "copy-success" : ""}
slot="end"
.path=${
this._copiedValue === entry.value ? mdiCheck : mdiContentCopy
}
></ha-svg-icon>
</ha-list-item-button>
`;
}
return html`
<ha-list-item-button .href=${entry.href}>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
${entry.icon ?? nothing}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
});
}
private async _copyValue(ev: HASSDomCurrentTargetEvent<HaListItemButton>) {
const value = ev.currentTarget.dataset.value;
if (value === undefined) {
return;
}
await copyToClipboard(value);
const duration = 4000;
this._copiedValue = value;
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = window.setTimeout(() => {
this._copiedValue = undefined;
this._copyFeedbackTimeout = undefined;
}, duration);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
duration,
});
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>
`
);
}
private _renderAttributes(attributes: { name: string; label: string }[]) {
@@ -460,63 +358,11 @@ class HaMoreInfoDetails extends LitElement {
}
ha-grouped-list + ha-grouped-list {
margin-top: var(--ha-space-6);
margin-top: var(--ha-space-4);
}
ha-list-item-button {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
--ha-row-item-gap: var(--ha-space-3);
--mdc-icon-size: 20px;
}
ha-list-item-button::part(end) {
gap: var(--ha-space-2);
}
ha-list-item-button img {
width: 20px;
height: 20px;
object-fit: contain;
}
.link-row {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--ha-space-3);
}
.link-row .label {
flex: 1;
color: var(--secondary-text-color);
}
.link-row .value {
max-width: 60%;
min-width: 0;
text-align: end;
overflow-wrap: anywhere;
}
.labels {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--ha-space-1);
}
.labels ha-label {
min-width: 0;
max-width: 100%;
}
ha-icon-next {
color: var(--secondary-text-color);
}
ha-svg-icon.copy-success {
color: var(--success-color);
a {
color: var(--primary-color);
}
.empty {
+2 -12
View File
@@ -28,7 +28,6 @@ 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";
@@ -48,10 +47,7 @@ import {
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import {
createMoreInfoUrl,
decodeMoreInfoUrl,
} from "../../common/url/more-info-query-params";
import { createMoreInfoUrl } 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";
@@ -235,13 +231,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
// 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
) {
if (this._returnUrl) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
-27
View File
@@ -117,33 +117,6 @@ 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.
+4 -22
View File
@@ -24,8 +24,6 @@ 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;
}
@@ -140,23 +138,10 @@ export class HassRouterPage extends ReactiveElement {
}
if (this._currentPage === newPage) {
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;
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
}
if (!routeOptions) {
@@ -380,10 +365,7 @@ export class HassRouterPage extends ReactiveElement {
this.updatePageEl(panelEl);
this.appendChild(panelEl);
if (
(routerOptions.cacheAll || routeOptions.cache) &&
!routeOptions.itemId
) {
if (routerOptions.cacheAll || routeOptions.cache) {
this._cache[page] = panelEl;
}
}
+22 -15
View File
@@ -1,9 +1,5 @@
import type { LitElement, PropertyValues } from "lit";
import type { UnsavedChangesGuard } from "../common/navigate";
import {
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../common/navigate";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import type { Constructor } from "../types";
export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
@@ -13,34 +9,45 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
/** Provided by `DirtyStateProviderMixin`. */
declare isDirtyState: boolean;
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
private _handleClick = async (e: MouseEvent) => {
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
const target = e.composedPath()[0];
if (!isNavigationClick(e)) {
return;
}
private _unsavedChangesGuard: UnsavedChangesGuard = {
isDirty: () => this.isDirtyState,
prompt: () => this.promptDiscardChanges(),
const result = await this.promptDiscardChanges();
if (result) {
this._removeListeners();
if (target) {
const newEvent = new MouseEvent(e.type, e);
target.dispatchEvent(newEvent);
}
}
};
public connectedCallback(): void {
super.connectedCallback();
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
registerUnsavedChangesGuard(this._unsavedChangesGuard);
private _removeListeners() {
window.removeEventListener("click", this._handleClick, true);
window.removeEventListener("beforeunload", this._handleUnload);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirtyState && this.isConnected) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
window.removeEventListener("beforeunload", this._handleUnload);
this._removeListeners();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
unregisterUnsavedChangesGuard(this._unsavedChangesGuard);
window.removeEventListener("beforeunload", this._handleUnload);
this._removeListeners();
}
protected async promptDiscardChanges(): Promise<boolean> {
@@ -3,10 +3,6 @@ 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";
@@ -111,8 +107,9 @@ 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,8 +14,6 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceActions,
deviceAutomationsEqual,
fetchDeviceActionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -42,8 +40,6 @@ export class HaDeviceAction extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -99,27 +95,13 @@ 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 {
// 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;
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -133,7 +115,6 @@ 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}
@@ -175,19 +156,6 @@ 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) {
@@ -217,15 +185,6 @@ 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", {
@@ -1,5 +1,4 @@
import "@home-assistant/webawesome/dist/components/tree-item/tree-item";
import type WaTreeItem from "@home-assistant/webawesome/dist/components/tree-item/tree-item";
import "@home-assistant/webawesome/dist/components/tree/tree";
import type { WaSelectionChangeEvent } from "@home-assistant/webawesome/dist/events/selection-change";
import { consume, type ContextType } from "@lit/context";
@@ -361,7 +360,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
? html`<ha-list-base>${floorAreas}</ha-list-base>`
: html`<wa-tree
@wa-selection-change=${this._handleSelectionChange}
@dblclick=${this._handleDoubleClick}
>${floorAreas}</wa-tree
>`
}`
@@ -1446,26 +1444,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
this._toggleItem(targetId, false);
}
private _handleDoubleClick(ev: MouseEvent) {
// the expand button and non-selectable items already toggle on single click
if (
ev
.composedPath()
.some((el) => (el as HTMLElement).classList?.contains("expand-button"))
) {
return;
}
const item = (ev.target as HTMLElement).closest<
WaTreeItem & { target: string }
>("wa-tree-item");
if (!item || item.isLeaf || item.preventSelection) {
return;
}
// avoid leaving the label text selected by the double click
window.getSelection()?.removeAllRanges();
this._toggleItem(item.target, !item.expanded);
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -7,11 +7,7 @@ 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 {
CONDITION_ROW_CONFIG_KEYS,
expandConditionWithShorthand,
pickRowConfig,
} from "../../../../data/automation";
import { expandConditionWithShorthand } from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
@@ -129,8 +125,9 @@ 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,8 +14,6 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceConditions,
deviceAutomationsEqual,
fetchDeviceConditionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -42,8 +40,6 @@ export class HaDeviceCondition extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -100,27 +96,13 @@ 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 {
// 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;
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -134,7 +116,6 @@ 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}
@@ -176,19 +157,6 @@ 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) {
@@ -219,15 +187,6 @@ 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", {
@@ -1,6 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
@@ -34,32 +35,40 @@ const stateConditionStruct = object({
enabled: optional(boolean()),
});
const SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
selector: {
attribute: {
hide_attributes: STATE_CONDITION_HIDDEN_ATTRIBUTES,
const SCHEMA = memoizeOne(
(hasAttribute: boolean) =>
[
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
selector: {
attribute: {
hide_attributes: STATE_CONDITION_HIDDEN_ATTRIBUTES,
},
},
context: {
filter_entity: "entity_id",
},
},
},
context: {
filter_entity: "entity_id",
},
},
{
name: "state",
required: true,
selector: {
state: { multiple: true },
},
context: {
filter_entity: "entity_id",
filter_attribute: "attribute",
},
},
{ name: "for", selector: { duration: {} } },
] as const;
{
name: "state",
required: true,
selector: {
state: { multiple: true },
},
context: {
filter_entity: "entity_id",
filter_attribute: "attribute",
},
},
// `for` is not supported together with `attribute`: the legacy state
// condition measures the duration against `last_changed`, which only
// updates on state changes, not attribute changes.
...(hasAttribute
? ([] as const)
: ([{ name: "for", selector: { duration: {} } }] as const)),
] as const
);
@customElement("ha-automation-condition-state")
export class HaStateCondition extends LitElement implements ConditionElement {
@@ -86,6 +95,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
}
protected render() {
const hasAttribute = !!this.condition.attribute;
const trgFor = createDurationData(this.condition.for);
const data = {
...this.condition,
@@ -97,10 +107,11 @@ export class HaStateCondition extends LitElement implements ConditionElement {
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.schema=${SCHEMA(hasAttribute)}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
></ha-form>
`;
}
@@ -115,6 +126,12 @@ export class HaStateCondition extends LitElement implements ConditionElement {
: {}
);
// `for` is not supported together with `attribute` for the legacy state
// condition, so drop any lingering duration when an attribute is set.
if (newCondition.attribute) {
delete newCondition.for;
}
// Ensure `state` stays an array for multi-select. If absent, set to []
if (newCondition.state === undefined || newCondition.state === "") {
newCondition.state = [];
@@ -124,7 +141,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
}
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string => {
switch (schema.name) {
case "entity_id":
@@ -144,6 +161,17 @@ export class HaStateCondition extends LitElement implements ConditionElement {
}
};
private _computeHelperCallback = (
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string | undefined => {
if (schema.name === "attribute" && this.condition.attribute) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.state.attribute_no_for"
);
}
return undefined;
};
static styles = css`
:host {
display: block;
@@ -896,15 +896,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
return;
}
this.yamlErrors = undefined;
resolve(true);
},
onClose: () => resolve(false),
onDiscard: () => {
this.yamlErrors = undefined;
this._markDirtyStateClean();
resolve(true);
},
onDiscard: () => resolve(true),
entityRegistryUpdate: this.entityRegistryUpdate,
entityRegistryEntry: this.registryEntry,
title: this.hass.localize(
@@ -278,9 +278,6 @@ 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);
@@ -297,9 +294,6 @@ 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,11 +47,9 @@ class HaConfigAutomation extends HassRouterPage {
},
edit: {
tag: "ha-automation-editor",
itemId: true,
},
show: {
tag: "ha-automation-editor",
itemId: true,
},
trace: {
tag: "ha-automation-trace",
@@ -7,7 +7,6 @@ import {
mdiFormatListBulleted,
mdiMenuDown,
mdiShape,
mdiSwapHorizontal,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import {
@@ -322,25 +321,14 @@ 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=${classMap({ target: true, warning: hasMissingTarget })}
>
<ha-svg-icon
.path=${hasMissingTarget ? mdiAlert : mdiFormatListBulleted}
></ha-svg-icon>
<button slot="trigger" class="target">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<div class="label">
${this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.targets",
@@ -479,15 +467,9 @@ export class HaAutomationRowTargets extends LitElement {
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
if (
targetType === "device" &&
this._compositeSplits?.[targetId]?.split_ids.some(
(id) => id in this._registries.devices
)
) {
if (targetType === "device" && this._compositeSplits?.[targetId]) {
// 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"
);
@@ -704,9 +686,6 @@ 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,11 +8,7 @@ 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 {
TRIGGER_ROW_CONFIG_KEYS,
migrateAutomationTrigger,
pickRowConfig,
} from "../../../../data/automation";
import { migrateAutomationTrigger } from "../../../../data/automation";
import type { TriggerDescription } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
@@ -148,8 +144,9 @@ 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,8 +16,6 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceTriggers,
deviceAutomationsEqual,
fetchDeviceTriggerCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -44,8 +42,6 @@ export class HaDeviceTrigger extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -104,27 +100,13 @@ 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 {
// 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;
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -138,7 +120,6 @@ 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}
@@ -180,19 +161,6 @@ 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) {
@@ -240,15 +208,6 @@ 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", {
@@ -266,6 +225,9 @@ export class HaDeviceTrigger extends LitElement {
) {
trigger = this._origTrigger;
}
if (this.trigger.id) {
trigger.id = this.trigger.id;
}
fireEvent(this, "value-changed", { value: trigger });
}
@@ -9,7 +9,6 @@ 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";
@@ -28,11 +27,15 @@ 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 {
@@ -57,7 +57,7 @@ export class CloudAccountOverview extends LitElement {
private _renderTopCard(): TemplateResult {
return html`
<ha-card outlined class="summary-card">
<ha-card outlined>
<div class="card-content">
<div
class="thank-you-header"
@@ -121,8 +121,8 @@ export class CloudAccountOverview extends LitElement {
<p class="muted">
${this.hass.localize("ui.panel.config.cloud.account.funding_note")}
</p>
${this._renderSubscriptionState()}
</div>
${this._renderSubscriptionState()}
</ha-card>
`;
}
@@ -132,7 +132,6 @@ export class CloudAccountOverview extends LitElement {
case "trial":
return html`
<ha-alert
class="subscription-alert"
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.trial_title"
@@ -157,7 +156,6 @@ export class CloudAccountOverview extends LitElement {
case "canceled":
return html`
<ha-alert
class="subscription-alert"
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.canceled_title"
@@ -183,7 +181,6 @@ export class CloudAccountOverview extends LitElement {
case "expired":
return html`
<ha-alert
class="subscription-alert"
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.expired_title"
@@ -639,15 +636,11 @@ export class CloudAccountOverview extends LitElement {
width: auto;
color: var(--primary-text-color);
}
/* Prevent the embedded .subscription-alert ha-alert from bleeding outside the card */
.summary-card {
overflow: hidden;
}
.subscription-alert {
ha-alert {
display: block;
--ha-alert-padding: var(--ha-space-3);
margin-top: var(--ha-space-3);
}
.subscription-alert ha-button[slot="action"] {
ha-alert ha-button[slot="action"] {
width: max-content;
white-space: nowrap;
}
@@ -21,10 +21,14 @@ import {
completeCloudOnboarding,
fetchCloudSubscriptionInfo,
ONBOARDING_ITEMS,
removeCloudData,
} from "../../../../data/cloud";
import type { Webhook } from "../../../../data/webhook";
import { fetchWebhooks } from "../../../../data/webhook";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../resources/styles";
@@ -37,7 +41,6 @@ import {
} from "./cloud-account-status";
import { showCloudOnboardingDialog } from "./show-dialog-cloud-onboarding";
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
import { confirmDeleteCloudData } from "../delete-cloud-data";
@customElement("cloud-account")
export class CloudAccount extends SubscribeMixin(LitElement) {
@@ -294,7 +297,33 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
}
private async _deleteCloudData() {
await confirmDeleteCloudData(this, this.hass, { signOutFirst: true });
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_text"
),
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await cloudLogout(this.hass);
await removeCloudData(this.hass);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_failed"
),
text: err?.message,
});
return;
} finally {
fireEvent(this, "ha-refresh-cloud-status");
}
}
private async _downloadSupportPackage() {
@@ -1,63 +0,0 @@
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import { showSupportPackageDialog } from "./account/show-dialog-cloud-support-package";
import { confirmDeleteCloudData } from "./delete-cloud-data";
// Recovery actions for the signed-out cloud pages, which all need to reach them
// — including while a registration is waiting on its email confirmation.
@customElement("cloud-signed-out-menu")
export class CloudSignedOutMenu extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
protected render(): TemplateResult {
return html`
<ha-dropdown @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="reset">
${this.hass.localize("ui.panel.config.cloud.account.reset_cloud_data")}
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="download">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
`;
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
switch (ev.detail.item.value) {
case "reset":
this._deleteCloudData();
break;
case "download":
showSupportPackageDialog(this);
break;
}
}
private async _deleteCloudData() {
await confirmDeleteCloudData(this, this.hass);
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-signed-out-menu": CloudSignedOutMenu;
}
}
@@ -1,24 +0,0 @@
import { css } from "lit";
export const cloudSignedOutStyle = css`
.content {
box-sizing: border-box;
display: flex;
flex-direction: column;
padding-bottom: calc(var(--safe-area-inset-bottom) + var(--ha-space-6));
}
ha-card {
width: 100%;
margin-bottom: 0;
}
h2 {
margin: 0;
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
white-space: normal;
overflow: visible;
text-overflow: clip;
text-wrap: balance;
}
`;
@@ -1,48 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { cloudLogout, removeCloudData } from "../../../data/cloud";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
// Confirms, then wipes every trace of the cloud account from this instance.
// `signOutFirst` is for callers reached while signed in: the session has to go
// before the data can.
export const confirmDeleteCloudData = async (
element: HTMLElement,
hass: HomeAssistant,
{ signOutFirst = false }: { signOutFirst?: boolean } = {}
): Promise<boolean> => {
const confirm = await showConfirmationDialog(element, {
title: hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_title"
),
text: hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_text"
),
confirmText: hass.localize("ui.panel.config.cloud.account.reset"),
destructive: true,
});
if (!confirm) {
return false;
}
try {
if (signOutFirst) {
await cloudLogout(hass);
}
await removeCloudData(hass);
} catch (err: any) {
showAlertDialog(element, {
title: hass.localize("ui.panel.config.cloud.account.reset_data_failed"),
text: err?.message,
});
return false;
} finally {
fireEvent(element, "ha-refresh-cloud-status");
}
return true;
};
@@ -169,8 +169,4 @@ declare global {
interface HTMLElementTagNameMap {
"cloud-forgot-password-card": CloudForgotPasswordCard;
}
interface HASSDomEvents {
"cloud-done": { flashMessage: string };
}
}
@@ -21,7 +21,7 @@ export class CloudForgotPassword extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/cloud/login"
back-path="/config"
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.title"
)}
+5 -58
View File
@@ -1,14 +1,12 @@
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import type { CloudStatus } from "../../../data/cloud";
import { cancelCloudAutoLogin } from "../../../data/cloud";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import type { ValueChangedEvent, HomeAssistant, Route } from "../../../types";
import "./account/cloud-account";
import "./start/cloud-start";
import "./login/cloud-login-panel";
const LOGGED_IN_URLS = [
"account",
@@ -20,12 +18,7 @@ const LOGGED_IN_URLS = [
"webhooks",
] as const;
const NOT_LOGGED_IN_URLS = [
"start",
"login",
"register",
"forgot-password",
] as const;
const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"] as const;
type CloudPage =
(typeof LOGGED_IN_URLS)[number] | (typeof NOT_LOGGED_IN_URLS)[number];
@@ -43,7 +36,7 @@ class HaConfigCloud extends HassRouterPage {
@property({ attribute: false }) public cloudStatus!: CloudStatus;
protected routerOptions: RouterOptions = {
defaultPage: "start",
defaultPage: "login",
showLoading: true,
initialLoad: () => this._cloudStatusLoaded,
// Guard the different pages based on if we're logged in.
@@ -52,30 +45,14 @@ class HaConfigCloud extends HassRouterPage {
if (!LOGGED_IN_URLS.some((url) => url === page)) {
return "account";
}
return undefined;
} else if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
return "login";
}
if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
return "start";
}
if (
page !== "register" &&
this.cloudStatus.auto_login &&
!this._autoLoginCancelled
) {
return "register";
}
return undefined;
},
routes: {
start: {
tag: "cloud-start",
},
login: {
tag: "cloud-login-panel",
load: () => import("./login/cloud-login-panel"),
},
register: {
tag: "cloud-register",
@@ -119,10 +96,6 @@ class HaConfigCloud extends HassRouterPage {
@state() private _loginEmail = "";
private _autoLoginCancelled = false;
private _lastPage = "";
private _resolveCloudStatusLoaded!: () => void;
private _cloudStatusLoaded = new Promise<void>((resolve) => {
@@ -149,14 +122,6 @@ class HaConfigCloud extends HassRouterPage {
navigate(this.route.prefix, { replace: true });
}
}
// A flash belongs to the page it was routed to. Cleared here rather than in
// beforeRender, which runs inside update() where assigning state schedules a
// second cycle.
if (this._lastPage === "login" && this._currentPage !== "login") {
this._flashMessage = "";
}
this._lastPage = this._currentPage;
}
protected createElement(tag: string) {
@@ -167,27 +132,9 @@ class HaConfigCloud extends HassRouterPage {
el.addEventListener("flash-message-changed", (ev) => {
this._flashMessage = (ev as ValueChangedEvent<string>).detail.value;
});
el.addEventListener("cloud-cancel-auto-login", () => {
this._autoLoginCancelled = true;
this._cancelAutoLogin();
});
el.addEventListener("cloud-auto-login-started", () => {
this._autoLoginCancelled = false;
});
return el;
}
private async _cancelAutoLogin() {
try {
await cancelCloudAutoLogin(this.hass);
} catch (_err) {
// Both callers are fire-and-forget navigations with nowhere to report
// this; the refresh below still reconciles whatever the backend kept.
} finally {
fireEvent(this, "ha-refresh-cloud-status");
}
}
protected updatePageEl(el) {
// We are not going to update if the current page if we are not logged in
// and the current page requires being logged in. Happens when we log out.
@@ -1,14 +1,28 @@
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list";
import "../../../../components/ha-list-item";
import "../../../../components/ha-svg-icon";
import { removeCloudData } from "../../../../data/cloud";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
import "./cloud-login";
import type { CloudLogin } from "./cloud-login";
@@ -16,88 +30,212 @@ import type { CloudLogin } from "./cloud-login";
export class CloudLoginPanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@property({ attribute: false }) public flashMessage?: string;
@query("cloud-login") private _cloudLoginElement?: CloudLogin;
protected firstUpdated(): void {
this._focusEmail();
}
@query("cloud-login") private _cloudLoginElement!: CloudLogin;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/cloud/start"
.header=${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
back-path="/config"
header="Home Assistant Cloud"
>
<div class="content">
${
this.flashMessage
? html`<ha-alert
dismissable
@alert-dismissed-clicked=${this._dismissFlash}
>
${this.flashMessage}
</ha-alert>`
: nothing
}
<cloud-login
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
.lead=${this.hass.localize(
"ui.panel.config.cloud.login.sign_in_lead"
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="reset">
${this.hass.localize(
"ui.panel.config.cloud.account.reset_cloud_data"
)}
check-connection
@cloud-forgot-password=${this._handleForgotPassword}
></cloud-login>
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="download">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction2"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
Nabu&nbsp;Casa,&nbsp;Inc</a
>${this.hass.localize(
"ui.panel.config.cloud.login.introduction2a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction3"
)}
</p>
<p>
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.login.learn_more_link"
)}
</a>
</p>
</div>
${
this.flashMessage
? html`<ha-alert
dismissable
@alert-dismissed-clicked=${this._dismissFlash}
>
${this.flashMessage}
</ha-alert>`
: ""
}
<cloud-login
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
@cloud-forgot-password=${this._handleForgotPassword}
check-connection
></cloud-login>
<ha-card outlined>
<ha-list>
<ha-list-item @click=${this._handleRegister} twoline hasMeta>
${this.hass.localize(
"ui.panel.config.cloud.login.start_trial"
)}
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.cloud.login.trial_info"
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</ha-list>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
private async _focusEmail() {
const cloudLogin = this._cloudLoginElement;
if (!cloudLogin) {
return;
}
await cloudLogin.updateComplete;
cloudLogin.emailField?.focus();
}
private _handleForgotPassword() {
this._dismissFlash();
fireEvent(this, "cloud-email-changed", {
value: this._cloudLoginElement?.emailField?.value ?? this.email ?? "",
value: this._cloudLoginElement.emailField.value ?? "",
});
navigate("/config/cloud/forgot-password");
}
private _handleRegister() {
this._dismissFlash();
fireEvent(this, "cloud-email-changed", {
value: this._cloudLoginElement.emailField.value ?? "",
});
navigate("/config/cloud/register");
}
private _dismissFlash() {
fireEvent(this, "flash-message-changed", { value: "" });
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
const value = ev.detail.item.value;
switch (value) {
case "reset":
this._deleteCloudData();
break;
case "download":
this._downloadSupportPackage();
break;
}
}
private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_text"
),
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await removeCloudData(this.hass);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_failed"
),
text: err?.message,
});
return;
} finally {
fireEvent(this, "ha-refresh-cloud-status");
}
}
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() {
return [
haStyle,
cloudSubpageStyle,
cloudSignedOutStyle,
css`
.content {
gap: var(--ha-space-4);
padding-bottom: 24px;
}
ha-alert,
cloud-login {
display: block;
width: 100%;
max-width: 600px;
margin-inline: auto;
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
h1 {
margin: 0;
}
`,
];
+32 -10
View File
@@ -9,12 +9,14 @@ import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import { setAssistPipelinePreferred } from "../../../../data/assist_pipeline";
import { cloudLogin } from "../../../../data/cloud";
import { loginHaCloud } from "../../../../data/onboarding";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../lovelace/custom-card-helpers";
import { showCloudAlreadyConnectedDialog } from "../dialog-cloud-already-connected/show-dialog-cloud-already-connected";
@@ -35,8 +37,6 @@ export class CloudLogin extends LitElement {
@property({ type: Boolean, attribute: "card-less" }) public cardLess = false;
@property() public lead?: string;
@query("#email", true) public emailField!: HaInput;
@query("#password", true) private _passwordField!: HaInput;
@@ -50,13 +50,21 @@ export class CloudLogin extends LitElement {
return this._renderLoginForm();
}
return html`<ha-card outlined>${this._renderLoginForm()}</ha-card>`;
return html`
<ha-card
outlined
.header=${this.localize(
`ui.panel.${this.translationKeyPanel}.login.sign_in`
)}
>
${this._renderLoginForm()}
</ha-card>
`;
}
private _renderLoginForm() {
return html`
<div class="card-content login-form">
${this.lead ? html`<p class="lead">${this.lead}</p>` : nothing}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -211,12 +219,28 @@ export class CloudLogin extends LitElement {
try {
if (this.hass) {
await cloudLogin({
const result = await cloudLogin({
hass: this.hass,
email,
...(code ? { code } : { password }),
check_connection: checkConnection,
});
if (result.cloud_pipeline) {
if (
await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.cloud_pipeline_title"
),
text: this.hass.localize(
"ui.panel.config.cloud.login.cloud_pipeline_text"
),
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
})
) {
setAssistPipelinePreferred(this.hass, result.cloud_pipeline);
}
}
} else {
// for onboarding
await loginHaCloud({
@@ -293,6 +317,9 @@ export class CloudLogin extends LitElement {
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
.card-actions {
display: flex;
justify-content: space-between;
@@ -302,11 +329,6 @@ export class CloudLogin extends LitElement {
display: flex;
flex-direction: column;
}
.lead {
margin: 0 0 var(--ha-space-2);
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
`,
];
}
+217 -537
View File
@@ -1,324 +1,199 @@
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 type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-spinner";
import "../../../../components/ha-svg-icon";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import type { CloudAutoLogin, CloudStatus } from "../../../../data/cloud";
import {
attemptCloudAutoLoginNow,
cloudRegisterAutoLogin,
resendCloudAutoLoginConfirm,
subscribeCloudEvents,
} from "../../../../data/cloud";
import { cloudRegister, cloudResendVerification } 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";
const BACK_PATH = "/config/cloud/start";
import "../../ha-config-section";
@customElement("cloud-register")
export class CloudRegister extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@state() private _view: "form" | "confirm" = "form";
@state() private _requestInProgress = false;
@state() private _resendInProgress = false;
@state() private _autoLoginError?: string;
@state() private _password = "";
@state() private _error?: string;
@state() private _success?: string;
@query("#email", true) private _emailField!: HaInput;
@state() private _registeredEmail = "";
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
@query("#email") private _emailField?: HaInput;
@query("#password") private _passwordField?: HaInput;
@query("#confirm-title") private _confirmTitle?: HTMLElement;
private get _autoLogin(): CloudAutoLogin | null {
const status = this.cloudStatus;
if (!status || status.logged_in) {
return null;
}
return status.auto_login ?? null;
}
private get _pendingEmail(): string {
return this._registeredEmail || this._autoLogin?.email || "";
}
public connectedCallback(): void {
super.connectedCallback();
if (this._autoLogin) {
this._view = "confirm";
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._stopWatchingAutoLogin();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (this._view !== "confirm") {
return;
}
const failedKey = this._autoLogin?.failed;
if (failedKey && !this._autoLoginError) {
this._failAutoLogin(failedKey);
return;
}
this._watchAutoLogin();
}
@query("#password", true) private _passwordField!: HaInput;
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}
.header=${this.hass.localize("ui.panel.config.cloud.register.headline")}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
>
<cloud-signed-out-menu
slot="toolbar-icon"
.hass=${this.hass}
></cloud-signed-out-menu>
<div class="content">
${confirming ? this._renderConfirm() : this._renderForm()}
</div>
</hass-subpage>
`;
}
private _renderForm(): TemplateResult {
return html`
<ha-card outlined>
<div class="card-header-block">
<h2>
${this.hass.localize(
"ui.panel.config.cloud.register.create_account"
)}
</h2>
<p>
${this.hass.localize("ui.panel.config.cloud.register.information")}
</p>
</div>
<div class="card-content register-form">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<ha-input
autofocus
id="email"
name="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
type="email"
autocomplete="email"
required
.value=${this.email ?? ""}
.disabled=${this._requestInProgress}
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-input>
<ha-input
id="password"
type="password"
password-toggle
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
)}
autocomplete="new-password"
minlength="8"
required
.disabled=${this._requestInProgress}
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-input>
<p class="terms">
${this.hass.localize(
"ui.panel.config.cloud.register.terms_notice",
{
terms: html`<a
href="https://www.nabucasa.com/tos/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.cloud.register.link_terms_conditions"
)}</a
>`,
privacy_policy: html`<a
href="https://www.nabucasa.com/privacy_policy/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.cloud.register.link_privacy_policy"
)}</a
>`,
}
)}
</p>
</div>
<div class="card-actions split">
<ha-button
appearance="plain"
.disabled=${this._requestInProgress}
@click=${this._handleSignInInstead}
>
${this.hass.localize(
"ui.panel.config.cloud.register.sign_in_instead"
)}
</ha-button>
<ha-progress-button
@click=${this._handleRegister}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.register.start_trial"
)}</ha-progress-button
>
</div>
</ha-card>
`;
}
private _renderConfirm(): TemplateResult {
return html`
<ha-card outlined>
<div class="card-content confirm">
${
this._autoLoginError
? html`<ha-alert alert-type="error">
${this._autoLoginError}
</ha-alert>`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
this._success
? html`<ha-alert alert-type="success">${this._success}</ha-alert>`
: nothing
}
<div class="confirm-icon">
<ha-svg-icon .path=${mdiEmailCheckOutline}></ha-svg-icon>
</div>
<h2 id="confirm-title" tabindex="-1">
${this.hass.localize(
"ui.panel.config.cloud.register.check_your_email"
)}
</h2>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.confirm_email_body",
{
email: html`<span class="email">${this._pendingEmail}</span>`,
}
)}
</p>
${
this._autoLoginError
? nothing
: html`
<div class="waiting">
<ha-spinner size="tiny"></ha-spinner>
<span>
${this.hass.localize(
"ui.panel.config.cloud.register.waiting_for_confirmation"
)}
</span>
</div>
`
}
</div>
<div class="card-actions split confirm-actions">
<ha-button appearance="plain" @click=${this._handleCancelPending}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
${
this._autoLoginError
? html`<ha-button
appearance="accent"
@click=${this._handleSignInInstead}
>
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
</ha-button>`
: html`<ha-button
appearance="accent"
@click=${this._handleAttemptNow}
>
${this.hass.localize(
"ui.panel.config.cloud.register.clicked_confirm"
)}
</ha-button>`
}
</div>
</ha-card>
${
this._autoLoginError
? nothing
: html`
<p class="footnote">
<ha-config-section .isWide=${this.isWide}>
<span slot="header"
>${this.hass.localize(
"ui.panel.config.cloud.register.headline"
)}</span
>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.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>`,
}
"ui.panel.config.cloud.register.information"
)}
</p>
`
}
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information2"
)}
</p>
<ul>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_remote_control"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_google_home"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_amazon_alexa"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_webhook_apps"
)}
</li>
</ul>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information3"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>
${this.hass.localize(
"ui.panel.config.cloud.register.information3a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information4"
)}
</p>
<ul>
<li>
<a
href="https://www.nabucasa.com/tos/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_terms_conditions"
)}
</a>
</li>
<li>
<a
href="https://www.nabucasa.com/privacy_policy/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_privacy_policy"
)}
</a>
</li>
</ul>
</div>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.register.create_account"
)}
><div class="card-content register-form">
${
this._error
? html`<ha-alert alert-type="error"
>${this._error}</ha-alert
>`
: ""
}
<ha-input
autofocus
id="email"
name="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
type="email"
autocomplete="email"
required
.value=${this.email ?? ""}
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-input>
<ha-input
id="password"
type="password"
password-toggle
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
)}
.value=${this._password}
autocomplete="new-password"
minlength="8"
required
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-input>
</div>
<div class="card-actions">
<button
class="link"
.disabled=${this._requestInProgress}
@click=${this._handleResendVerifyEmail}
>
${this.hass.localize(
"ui.panel.config.cloud.register.resend_confirm_email"
)}
</button>
<ha-progress-button
@click=${this._handleRegister}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.register.start_trial"
)}</ha-progress-button
>
</div>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
@@ -328,50 +203,10 @@ export class CloudRegister extends LitElement {
}
}
private _handleSignInInstead() {
const confirming = this._view === "confirm";
fireEvent(this, "cloud-email-changed", {
// The field wins on the form: it holds whatever was typed after a cancel.
value: this._emailField?.value || this._pendingEmail || this.email || "",
});
this._stopWatchingAutoLogin();
if (confirming) {
fireEvent(this, "cloud-cancel-auto-login");
}
// Replaces only from the waiting view, whose registration is being
// cancelled: browser-back must not return to it. The form is a page worth
// going back to.
navigate("/config/cloud/login", { replace: confirming });
}
private _handleConfirmBack = () => {
this._stopWatchingAutoLogin();
fireEvent(this, "cloud-cancel-auto-login");
navigate(BACK_PATH, { replace: true });
};
private _handleCancelPending() {
this._resetToForm();
fireEvent(this, "cloud-cancel-auto-login");
}
private _resetToForm() {
this._stopWatchingAutoLogin();
this._view = "form";
this._autoLoginError = undefined;
this._registeredEmail = "";
this._error = undefined;
this._success = undefined;
}
private async _handleRegister() {
const emailField = this._emailField;
const passwordField = this._passwordField;
if (!emailField || !passwordField) {
return;
}
if (!emailField.reportValidity()) {
passwordField.reportValidity();
emailField.focus();
@@ -387,246 +222,92 @@ export class CloudRegister extends LitElement {
const password = passwordField.value || "";
this._requestInProgress = true;
this._error = undefined;
try {
await cloudRegisterAutoLogin(this.hass, email, password);
await cloudRegister(this.hass, email, password);
this._verificationEmailSent(email, "account_created");
} catch (err: any) {
this._password = "";
this._requestInProgress = false;
passwordField.value = "";
this._error =
err?.body?.message || this.hass.localize("ui.common.unknown_error");
return;
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
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) {
const emailField = this._emailField;
if (!emailField.reportValidity()) {
emailField.focus();
return;
}
this._error = undefined;
this._success = undefined;
this._resendInProgress = true;
const email = emailField.value || "";
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;
}
this._requestInProgress = true;
const doResend = async (username: string) => {
try {
await cloudResendVerification(this.hass, username);
this._verificationEmailSent(username, "verification_email_sent");
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doResend(username.toLowerCase());
} else {
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
};
await doResend(email);
}
private _verificationEmailSent(
email: string,
messageKey: "account_created" | "verification_email_sent"
) {
this._requestInProgress = false;
this._password = "";
fireEvent(this, "cloud-email-changed", { value: email });
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
`ui.panel.config.cloud.register.${messageKey}`
),
});
}
static get styles() {
return [
haStyle,
cloudSubpageStyle,
cloudSignedOutStyle,
css`
.content {
gap: var(--ha-space-3);
[slot="introduction"] {
margin: -1em 0;
}
.card-header-block {
padding: var(--ha-space-4) var(--ha-space-4) 0;
[slot="introduction"] a {
color: var(--primary-color);
}
.card-header-block p {
margin: var(--ha-space-2) 0 0;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
a {
color: var(--primary-color);
}
h1 {
margin: 0;
}
.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 {
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-actions.confirm-actions {
border-top: none;
}
.confirm {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--ha-space-3);
padding: var(--ha-space-8) var(--ha-space-4) var(--ha-space-5);
}
.confirm ha-alert {
width: 100%;
text-align: initial;
}
.confirm-icon {
width: 56px;
height: 56px;
border-radius: var(--ha-border-radius-pill);
background: color-mix(in srgb, var(--info-color) 18%, transparent);
color: var(--info-color);
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 28px;
}
/* Focused programmatically to announce the view; no visible ring. */
#confirm-title:focus {
outline: none;
}
.confirm p {
margin: 0;
max-width: 420px;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.confirm .email {
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
}
.waiting {
display: flex;
align-items: center;
gap: var(--ha-space-2);
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.footnote {
display: block;
width: 100%;
max-width: 600px;
margin: 0 auto;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
text-wrap: pretty;
}
/* Not column-reverse: that paints the buttons against DOM order, so
keyboard and screen-reader users reach them back to front. */
@media (max-width: 500px) {
.card-actions.split {
flex-direction: column;
align-items: stretch;
gap: var(--ha-space-2);
}
}
`,
];
}
@@ -638,7 +319,6 @@ declare global {
}
interface HASSDomEvents {
"cloud-cancel-auto-login": undefined;
"cloud-auto-login-started": undefined;
"cloud-done": { flashMessage: string };
}
}
@@ -1,300 +0,0 @@
import {
mdiBackupRestore,
mdiCellphone,
mdiEarth,
mdiHandHeart,
mdiMicrophone,
mdiMicrophoneMessage,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { navigate } from "../../../../common/navigate";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../cloud-signed-out-menu";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
const FEATURES = [
{ icon: mdiHandHeart, color: "--red-color", key: "feature_support" },
{ icon: mdiEarth, color: "--blue-color", key: "feature_remote" },
{ icon: mdiBackupRestore, color: "--green-color", key: "feature_backup" },
{
icon: mdiMicrophoneMessage,
color: "--cyan-color",
key: "feature_voice_control",
},
{
icon: mdiMicrophone,
color: "--purple-color",
key: "feature_voice_quality",
},
{ icon: mdiCellphone, color: "--primary-color", key: "feature_companion" },
] as const;
@customElement("cloud-start")
export class CloudStart extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize(
"ui.panel.config.cloud.login.landing_title"
)}
>
<cloud-signed-out-menu
slot="toolbar-icon"
.hass=${this.hass}
></cloud-signed-out-menu>
<div class="content">
<div class="landing">
<div class="pitch">
<h2>
${this.hass.localize("ui.panel.config.cloud.login.hero_title")}
</h2>
<p class="lead">
${this.hass.localize("ui.panel.config.cloud.login.hero_lead")}
</p>
</div>
<ha-card outlined class="features">
<div class="card-content feature-list">
${FEATURES.map(
(feature) => html`
<div class="feature">
<div
class="icon-tile"
style=${styleMap({
"--feature-tint": `var(${feature.color})`,
})}
>
<ha-svg-icon .path=${feature.icon}></ha-svg-icon>
</div>
<div class="feature-text">
<div class="feature-title">
${this.hass.localize(
`ui.panel.config.cloud.login.${feature.key}_title`
)}
</div>
<p>
${this.hass.localize(
`ui.panel.config.cloud.login.${feature.key}_body`
)}
</p>
</div>
</div>
`
)}
</div>
</ha-card>
<div class="actions">
<div class="action-buttons">
<ha-button
size="l"
appearance="accent"
@click=${this._handleRegister}
>
${this.hass.localize(
"ui.panel.config.cloud.register.headline"
)}
</ha-button>
<ha-button appearance="plain" @click=${this._handleSignIn}>
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
</ha-button>
</div>
<p class="trial-note">
${this.hass.localize("ui.panel.config.cloud.login.trial_note")}
</p>
</div>
</div>
<p class="footnote">
${this.hass.localize("ui.panel.config.cloud.login.partner_note", {
nabu_casa_link: html`<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>`,
})}
</p>
</div>
</hass-subpage>
`;
}
private _handleSignIn() {
navigate("/config/cloud/login");
}
private _handleRegister() {
navigate("/config/cloud/register");
}
static get styles() {
return [
haStyle,
cloudSubpageStyle,
cloudSignedOutStyle,
css`
.content {
min-height: 100%;
gap: var(--ha-space-4);
container-type: inline-size;
}
.landing {
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
width: 100%;
max-width: 600px;
margin-inline: auto;
}
.pitch .lead {
margin: var(--ha-space-2) 0 0;
line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color);
text-wrap: pretty;
}
.feature-list {
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
padding: var(--ha-space-5) var(--ha-space-4);
}
.feature {
display: flex;
gap: var(--ha-space-3);
align-items: flex-start;
}
.feature-title {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.feature-text p {
margin: var(--ha-space-1) 0 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
text-wrap: pretty;
}
.icon-tile {
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-pill);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: color-mix(in srgb, var(--feature-tint) 15%, transparent);
color: var(--feature-tint);
}
/* Keeps the primary action reachable while the feature list scrolls. */
.actions {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
padding: var(--ha-space-3) 0
calc(var(--safe-area-inset-bottom) + var(--ha-space-3));
background: var(--primary-background-color);
box-shadow: 0 -1px 0 var(--divider-color);
}
.action-buttons {
display: flex;
gap: var(--ha-space-2);
}
.action-buttons ha-button {
flex: 1;
/* Matches the primary action's size="l" height without also giving
the secondary its larger type. */
--ha-button-height: 48px;
}
.trial-note {
margin: 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
}
.footnote {
width: 100%;
max-width: 960px;
/* Keep the footer at the bottom on short pages. */
margin: auto auto 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
text-wrap: pretty;
}
.footnote a {
color: var(--primary-color);
}
@container (min-width: 700px) {
.landing {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
grid-template-rows: auto auto 1fr;
column-gap: var(--ha-space-7);
row-gap: var(--ha-space-6);
align-items: start;
max-width: 960px;
}
.pitch {
grid-column: 1;
grid-row: 1;
}
.actions {
grid-column: 1;
grid-row: 2;
position: static;
padding: 0;
background: none;
box-shadow: none;
}
.features {
grid-column: 2;
grid-row: 1 / 4;
}
.action-buttons {
flex-direction: column;
max-width: 340px;
}
.action-buttons ha-button {
flex: initial;
width: 100%;
}
.trial-note {
text-align: start;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-start": CloudStart;
}
}
+3 -41
View File
@@ -1,10 +1,9 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { CloudStatus } from "../../data/cloud";
import { fetchCloudStatus, subscribeCloudEvents } from "../../data/cloud";
import { fetchCloudStatus } from "../../data/cloud";
import {
entityRegistryByEntityId,
entityRegistryById,
@@ -257,10 +256,6 @@ class HaPanelConfig extends HassRouterPage {
private _listeners: (() => void)[] = [];
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
private _cloudStatusRequestId = 0;
public connectedCallback() {
super.connectedCallback();
this._listeners.push(
@@ -273,10 +268,6 @@ class HaPanelConfig extends HassRouterPage {
this._wideSidebar = matches;
})
);
if (this.hass && isComponentLoaded(this.hass.config, "cloud")) {
this._subscribeCloudEvents();
this._updateCloudStatus();
}
}
public disconnectedCallback() {
@@ -284,42 +275,19 @@ class HaPanelConfig extends HassRouterPage {
while (this._listeners.length) {
this._listeners.pop()!();
}
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
this._unsubCloudEvents = undefined;
entityRegistryByEntityId.clear();
entityRegistryById.clear();
}
private _subscribeCloudEvents() {
if (
this._unsubCloudEvents ||
!this.hass ||
!isComponentLoaded(this.hass.config, "cloud")
) {
return;
}
const subscription = subscribeCloudEvents(this.hass, () => {
this._updateCloudStatus();
});
this._unsubCloudEvents = subscription;
subscription.catch(() => {
if (this._unsubCloudEvents === subscription) {
this._unsubCloudEvents = undefined;
}
});
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
this.hass.loadBackendTranslation("services");
if (isComponentLoaded(this.hass.config, "cloud")) {
this._updateCloudStatus();
this.addEventListener("connection-status", (ev) => {
if (ev.detail === "connected") {
this._updateCloudStatus();
this._subscribeCloudEvents();
}
});
}
@@ -353,13 +321,7 @@ class HaPanelConfig extends HassRouterPage {
}
private async _updateCloudStatus() {
const requestId = ++this._cloudStatusRequestId;
const status = await fetchCloudStatus(this.hass);
if (requestId !== this._cloudStatusRequestId) {
return;
}
this._cloudStatus = status;
this._cloudStatus = await fetchCloudStatus(this.hass);
if (
// Relayer connecting
@@ -256,14 +256,10 @@ 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;
@@ -285,6 +281,10 @@ 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,13 +1,11 @@
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-adaptive-dialog";
import "../../../../../components/ha-icon-button";
import "../../../../../components/item/ha-list-item-value";
import "../../../../../components/list/ha-grouped-list";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
@@ -87,34 +85,64 @@ class DialogSerialPortInfo extends LitElement {
}
return html`
<ha-adaptive-dialog
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.serial.port_information"
)}
@closed=${this._dialogClosed}
>
<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>
<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>
`;
}
static readonly styles: CSSResultGroup = css`
ha-grouped-list {
--ha-list-item-value-max-width: 80%;
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);
}
`;
}
@@ -192,16 +192,6 @@ 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"
@@ -212,26 +202,21 @@ export class SerialConfigDashboard extends LitElement {
<ha-md-list-item type="link" href=${href} class="consumer">
${
consumer.kind === "config_entry"
? this._renderConsumerIcon(
brandsUrl(
? html`<img
slot="start"
.src=${brandsUrl(
{
domain: consumer.domain!,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
),
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>`
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${consumer.domain!}
/>`
: 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>
@@ -1,4 +1,3 @@
import { mdiSpiderWeb } from "@mdi/js";
import type {
CallbackDataParams,
TopLevelFormatterParams,
@@ -17,7 +16,6 @@ import type {
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-icon-button";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
@@ -27,7 +25,6 @@ import type {
ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js";
import {
fetchZwaveNetworkNeighbors,
fetchZwaveNetworkStatus,
getNodeIdFromDevice,
NodeStatus,
@@ -36,7 +33,6 @@ import {
import "../../../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
@customElement("zwave_js-network-visualization")
export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@@ -59,19 +55,11 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _devices: Record<string, DeviceRegistryEntry> = {};
@state() private _neighbors?: Record<number, number[]>;
@state() private _showNeighbors = false;
@state() private _searchFilter = "";
// Route statistics reference repeaters by device registry ID
private _nodeIdsByDeviceId: Record<string, number> = {};
private _neighborLinks = new Set<string>();
private _loadingNeighbors = false;
public hassSubscribe() {
const subscriptions: Promise<UnsubscribeFunc>[] = [];
const devices: Record<number, DeviceRegistryEntry> = {};
@@ -101,32 +89,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
return subscriptions;
}
private async _toggleNeighbors() {
this._showNeighbors = !this._showNeighbors;
if (!this._showNeighbors || this._neighbors || this._loadingNeighbors) {
return;
}
// fetched on demand: reading neighbors turns the radio off briefly
this._loadingNeighbors = true;
try {
this._neighbors = await fetchZwaveNetworkNeighbors(
this.hass,
this.configEntryId
);
} catch (err: unknown) {
this._showNeighbors = false;
showToast(this, {
message:
(err as { message?: string }).message ??
this.hass.localize(
"ui.panel.config.zwave_js.visualization.neighbors_error"
),
});
} finally {
this._loadingNeighbors = false;
}
}
public connectedCallback() {
super.connectedCallback();
this._fetchNetworkStatus();
@@ -154,23 +116,13 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
.searchFilter=${this._searchFilter}
.data=${this._getNetworkData(
this._nodeStatuses,
this._nodeStatistics,
this._showNeighbors ? this._neighbors : undefined
this._nodeStatistics
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow ? this._renderInputSearch("search") : nothing}
<ha-icon-button
slot="button"
class=${this._showNeighbors ? "active" : "inactive"}
.path=${mdiSpiderWeb}
.label=${this.hass.localize(
"ui.panel.config.zwave_js.visualization.toggle_neighbors"
)}
@click=${this._toggleNeighbors}
></ha-icon-button>
</ha-network-graph>
</hass-subpage>
`;
@@ -232,13 +184,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
if (this._neighborLinks.has(`${source}>${target}`)) {
return html`${sourceName}${targetName}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.neighbor"
)}</b
>`;
}
// links point away from the controller, so the route belongs to the target
const stats =
this._nodeStatistics[target] ?? this._nodeStatistics[source];
@@ -318,8 +263,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
private _getNetworkData = memoizeOne(
(
nodeStatuses: Record<number, ZWaveJSNodeStatus>,
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>,
neighbors: Record<number, number[]> | undefined
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>
): NetworkData => {
const style = getComputedStyle(this);
const nodes: NetworkNode[] = [];
@@ -476,49 +420,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
});
});
// Neighbors are the nodes a node can reach directly. They are symmetric
// and carry no signal information, so they fill in the mesh underneath
// the measured routes without overriding them.
const neighborLinks: NetworkLink[] = [];
const neighborKeys = new Set<string>();
Object.entries(neighbors ?? {}).forEach(([nodeId, neighborIds]) => {
neighborIds.forEach((neighborId) => {
const target = String(neighborId);
if (!nodeStatuses[neighborId] || target === nodeId) {
return;
}
const [a, b] = [nodeId, target].sort();
const key = `${a}>${b}`;
if (
neighborKeys.has(key) ||
links.some(
(link) =>
(link.source === nodeId && link.target === target) ||
(link.source === target && link.target === nodeId)
)
) {
return;
}
neighborKeys.add(key);
neighborLinks.push({
source: a,
target: b,
// equal values in both directions render the link without an arrow
value: 1,
reverseValue: 1,
lineStyle: {
width: 1,
color: style.getPropertyValue("--disabled-color"),
type: "dashed",
},
// neighbors are plentiful, let the routes shape the layout
ignoreForceLayout: true,
});
});
});
this._neighborLinks = neighborKeys;
return { nodes, links: [...neighborLinks, ...links], categories };
return { nodes, links, categories };
}
);
@@ -558,17 +460,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
ha-input-search {
flex: 1;
}
/* ha-chart-base can't style re-slotted buttons, so mirror its look */
ha-icon-button[slot="button"] {
background: var(--card-background-color);
border-radius: var(--ha-border-radius-sm);
--ha-icon-button-size: 32px;
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
ha-icon-button[slot="button"].inactive {
color: var(--state-inactive-color);
}
`,
];
}
@@ -40,7 +40,6 @@ class HaConfigScene extends HassRouterPage {
},
edit: {
tag: "ha-scene-editor",
itemId: true,
},
},
};
+18 -33
View File
@@ -911,15 +911,11 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _subscribeEvents() {
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;
this._unsubscribeEvents =
await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
}
private _showMoreInfo(ev: Event) {
@@ -932,9 +928,6 @@ 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
@@ -950,10 +943,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return;
}
if (!this.isConnected) {
return;
}
if (!config.entities) {
config.entities = {};
}
@@ -1126,24 +1115,20 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _confirmUnsavedChanged(): Promise<boolean> {
if (!this.isDirtyState) {
return true;
if (this.isDirtyState) {
return showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
),
text: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_text"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
}
const confirmed = await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
),
text: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_text"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
if (confirmed) {
this._markDirtyStateClean();
}
return confirmed;
return true;
}
private async _duplicate() {
@@ -40,11 +40,9 @@ class HaConfigScript extends HassRouterPage {
},
edit: {
tag: "ha-script-editor",
itemId: true,
},
show: {
tag: "ha-script-editor",
itemId: true,
},
trace: {
tag: "ha-script-trace",
+1 -6
View File
@@ -804,15 +804,10 @@ export class HaScriptEditor extends SubscribeMixin(
return;
}
this.yamlErrors = undefined;
resolve(true);
},
onClose: () => resolve(false),
onDiscard: () => {
this.yamlErrors = undefined;
this._markDirtyStateClean();
resolve(true);
},
onDiscard: () => resolve(true),
entityRegistryUpdate: this.entityRegistryUpdate,
entityRegistryEntry: this.registryEntry,
title: this.hass.localize(
@@ -52,11 +52,11 @@ import type {
} from "../../../../data/recorder";
import {
clearStatistics,
getStatisticIds,
StatisticMeanType,
updateStatisticsIssues,
validateStatistics,
} from "../../../../data/recorder";
import { getStatisticIds } from "../../../../data/recorder_statistic_ids";
import {
apiContext,
internationalizationContext,
@@ -395,11 +395,9 @@ export class VoiceAssistantsExpose extends LitElement {
aliases: entry?.aliases || [],
};
}
if (result[entityId]) {
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
}
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
});
}
return Object.values(result);
+7 -1
View File
@@ -266,6 +266,7 @@ class HaPanelHistory extends LitElement {
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
@@ -809,7 +810,12 @@ class HaPanelHistory extends LitElement {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
}
.progress-wrapper {
@@ -188,7 +188,7 @@ class HuiHistoryChartCardFeature
}
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container no-history-found">
<div class="container">
<div class="info">
${this.hass!.localize(
"ui.components.history_charts.no_history_found"
@@ -280,14 +280,6 @@ class HuiHistoryChartCardFeature
pointer-events: none !important;
}
.no-history-found {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
hui-graph-base {
width: 100%;
--accent-color: var(--feature-color);
@@ -217,34 +217,22 @@ export class HuiClockCardDigital extends LitElement {
"hour minute second"
"hour minute am-pm";
font-size: var(
--ha-clock-card-digital-font-size-small,
var(--ha-font-size-2xl)
);
font-size: 1.5rem;
font-weight: var(--ha-font-weight-medium);
line-height: 0.8;
direction: ltr;
}
.time-title + .time-parts {
font-size: var(
--ha-clock-card-digital-font-size-small,
var(--ha-font-size-2xl)
);
font-size: 1.5rem;
}
.time-parts.size-medium {
font-size: var(
--ha-clock-card-digital-font-size-medium,
calc(48px * var(--ha-font-size-scale))
);
font-size: 3rem;
}
.time-parts.size-large {
font-size: var(
--ha-clock-card-digital-font-size-large,
calc(64px * var(--ha-font-size-scale))
);
font-size: 4rem;
}
.time-parts.size-medium .time-part.second,
@@ -1,39 +0,0 @@
/**
* 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,7 +44,6 @@ 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",
@@ -586,8 +585,7 @@ 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. Combined used_grid is a fallback for
// multi-source battery charging; skip it when it has no points.
// entries for grid-only setups.
if (statIdsByCat.solar) {
combinedData.used_solar = { used_solar: consumptionData.used_solar };
}
@@ -598,14 +596,38 @@ export class HuiEnergyUsageGraphCard
}
if (combinedData.from_grid && summedData.to_battery) {
const used_grid = buildCombinedUsedGrid(
combinedData.from_grid,
consumptionData.grid_to_battery,
consumptionData.used_grid
);
if (used_grid) {
combinedData.used_grid = { used_grid };
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];
}
}
combinedData.used_grid = { used_grid };
}
const uniqueKeys = summedData.timestamps;
@@ -1,15 +1,8 @@
import type { LovelacePanelConfig } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../data/lovelace/config/types";
import {
fetchConfig,
isStrategyDashboard,
saveConfig,
} from "../../../data/lovelace/config/types";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import { fetchConfig, saveConfig } from "../../../data/lovelace/config/types";
import { fetchDashboards } from "../../../data/lovelace/dashboard";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
@@ -44,15 +37,7 @@ export const addEntitiesToLovelaceView = async (
return;
}
// A strategy dashboard has no views of its own until the user takes control.
const hasViewList = (
config: LovelaceRawConfig | undefined
): config is LovelaceConfig => !!config && !isStrategyDashboard(config);
const hasViews = (config: LovelaceRawConfig | undefined) =>
hasViewList(config) && !!config.views?.length;
let lovelaceConfig: LovelaceRawConfig | undefined;
let lovelaceConfig;
let urlPath: string | null = null;
if (mainLovelaceMode === "storage") {
try {
@@ -62,36 +47,25 @@ export const addEntitiesToLovelaceView = async (
}
}
if (!hasViews(lovelaceConfig)) {
// Prefer a dashboard that has views to add the card to, but keep the first
// usable one as a fallback so the user still gets the dashboard picker.
if (!lovelaceConfig && storageDashs.length) {
// find first dashoard not in generated mode
for (const storageDash of storageDashs) {
try {
// eslint-disable-next-line no-await-in-loop
const dashConfig = await fetchConfig(
lovelaceConfig = await fetchConfig(
hass.connection,
storageDash.url_path,
false
);
if (!hasViewList(dashConfig)) {
continue;
}
if (!hasViewList(lovelaceConfig)) {
lovelaceConfig = dashConfig;
urlPath = storageDash.url_path;
}
if (hasViews(dashConfig)) {
lovelaceConfig = dashConfig;
urlPath = storageDash.url_path;
break;
}
urlPath = storageDash.url_path;
break;
} catch (_err: any) {
// dashboard is in generated mode
}
}
}
if (!hasViewList(lovelaceConfig)) {
if (!lovelaceConfig) {
if (dashboards.length > storageDashs.length) {
// all storage dashboards are generated, but we have YAML dashboards just show the YAML config
showSuggestCardDialog(element, {
@@ -120,7 +94,7 @@ export const addEntitiesToLovelaceView = async (
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
lovelaceConfig,
lovelaceConfig: lovelaceConfig!,
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
try {
await saveConfig(hass!, null, newConfig);
@@ -9,14 +9,13 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-list";
import "../../../../components/ha-radio-list-item";
import "../../../../components/ha-select";
import "../../../../components/ha-spinner";
import "../../../../components/ha-dialog";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { fetchConfig } from "../../../../data/lovelace/config/types";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
import { LOVELACE_PANEL } from "../../../../data/panel";
import { getDefaultPanelUrlPath } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { SelectViewDialogParams } from "./show-select-view-dialog";
@@ -43,15 +42,11 @@ export class HuiDialogSelectView extends LitElement {
@state() private _selectedViewIdx = 0;
@state() private _loading = false;
@state() private _open = false;
public showDialog(params: SelectViewDialogParams): void {
this._config = params.lovelaceConfig;
this._urlPath = params.urlPath;
this._selectedViewIdx = 0;
this._loading = false;
this._params = params;
this._open = true;
if (this._params.allowDashboardChange) {
@@ -73,6 +68,8 @@ export class HuiDialogSelectView extends LitElement {
return nothing;
}
const defaultPanel = getDefaultPanelUrlPath(this.hass);
return html`
<ha-dialog
.open=${this._open}
@@ -89,19 +86,19 @@ export class HuiDialogSelectView extends LitElement {
"ui.panel.lovelace.editor.select_view.dashboard_label"
)}
.disabled=${!this._dashboards.length}
.value=${this._urlPath ?? LOVELACE_PANEL}
.value=${this._urlPath || defaultPanel}
@selected=${this._dashboardChanged}
autofocus
.options=${this._dashboards
.map((dashboard) => ({
value: dashboard.url_path,
label: `${dashboard.title}${dashboard.id === LOVELACE_PANEL ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
label: `${dashboard.title}${dashboard.id === "lovelace" ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
disabled: dashboard.mode !== "storage",
}))
.sort((a, b) =>
a.value === LOVELACE_PANEL
a.value === "lovelace"
? -1
: b.value === LOVELACE_PANEL
: b.value === "lovelace"
? 1
: a.label.localeCompare(b.label)
)}
@@ -109,7 +106,54 @@ export class HuiDialogSelectView extends LitElement {
</ha-select>`
: nothing
}
${this._renderViews()}
${
!this._config || (this._config.views || []).length < 1
? html`<ha-alert alert-type="error"
>${this.hass.localize(
this._config
? "ui.panel.lovelace.editor.select_view.no_views"
: "ui.panel.lovelace.editor.select_view.no_config"
)}</ha-alert
>`
: this._config.views.length > 1
? html`
<ha-list>
${this._config.views.map((view, idx) => {
const isStrategy = isStrategyView(view);
return html`
<ha-radio-list-item
.graphic=${
this._config?.views.some(({ icon }) => icon)
? "icon"
: nothing
}
@click=${this._viewChanged}
.value=${idx.toString()}
.selected=${this._selectedViewIdx === idx}
.disabled=${
isStrategy && !this._params?.includeStrategyViews
}
?autofocus=${
idx === 0 && !this._params!.allowDashboardChange
}
>
<span>
${view.title}${
isStrategy
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
: nothing
}
</span>
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
</ha-radio-list-item>
`;
})}
</ha-list>
`
: nothing
}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@@ -120,7 +164,7 @@ export class HuiDialogSelectView extends LitElement {
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._selectableConfig}
.disabled=${!this._config || (this._config.views || []).length < 1}
@click=${this._selectView}
>
${this._params.actionLabel || this.hass!.localize("ui.common.move")}
@@ -130,97 +174,30 @@ export class HuiDialogSelectView extends LitElement {
`;
}
// While a config is loading the views on screen still belong to the
// previously selected dashboard, so nothing may be picked from them.
private get _selectableConfig(): LovelaceConfig | undefined {
return !this._loading && this._config?.views?.length
? this._config
: undefined;
}
private _renderViews() {
if (this._loading) {
return html`<div class="loading">
<ha-spinner size="medium"></ha-spinner>
</div>`;
}
if (!this._selectableConfig) {
return html`<ha-alert alert-type="error">
${this.hass.localize(
this._config
? "ui.panel.lovelace.editor.select_view.no_views"
: "ui.panel.lovelace.editor.select_view.no_config"
)}
</ha-alert>`;
}
const views = this._selectableConfig.views;
if (views.length < 2) {
return nothing;
}
const hasIcon = views.some(({ icon }) => icon);
return html`
<ha-list>
${views.map((view, idx) => {
const isStrategy = isStrategyView(view);
return html`
<ha-radio-list-item
.graphic=${hasIcon ? "icon" : nothing}
@click=${this._viewChanged}
.value=${idx.toString()}
.selected=${this._selectedViewIdx === idx}
.disabled=${isStrategy && !this._params?.includeStrategyViews}
?autofocus=${idx === 0 && !this._params!.allowDashboardChange}
>
<span>
${view.title}${
isStrategy
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
: nothing
}
</span>
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
</ha-radio-list-item>
`;
})}
</ha-list>
`;
}
private async _getDashboards() {
this._dashboards =
this._params!.dashboards || (await fetchDashboards(this.hass));
}
private async _dashboardChanged(ev: ValueChangedEvent<string>) {
const urlPath = ev.detail.value === LOVELACE_PANEL ? null : ev.detail.value;
let urlPath: string | null = ev.detail.value;
if (urlPath === this._urlPath) {
return;
}
if (urlPath === "lovelace") {
urlPath = null;
}
this._urlPath = urlPath;
this._loading = true;
let config: LovelaceConfig | undefined;
this._selectedViewIdx = 0;
try {
config = (await fetchConfig(
this._config = (await fetchConfig(
this.hass.connection,
urlPath,
false
)) as LovelaceConfig;
} catch (_err: any) {
config = undefined;
this._config = undefined;
}
// Responses can resolve out of order.
if (urlPath !== this._urlPath) {
return;
}
this._config = config;
this._selectedViewIdx = 0;
this._loading = false;
}
private _viewChanged(e) {
@@ -232,14 +209,10 @@ export class HuiDialogSelectView extends LitElement {
}
private _selectView(): void {
const config = this._selectableConfig;
if (!config) {
return;
}
fireEvent(this, "view-selected", { view: this._selectedViewIdx });
this._params!.viewSelectedCallback(
this._urlPath!,
config,
this._config!,
this._selectedViewIdx
);
this.closeDialog();
@@ -252,10 +225,6 @@ export class HuiDialogSelectView extends LitElement {
ha-select {
width: 100%;
}
.loading {
display: flex;
justify-content: center;
}
mwc-radio-list-item {
direction: ltr;
}
@@ -17,7 +17,7 @@ import type { HomeAssistant } from "../../../../types";
import {
isMediaSourceContentId,
resolveMediaSourceWithCache,
resolveMediaSource,
} from "../../../../data/media_source";
export interface BackgroundConfigTarget {
@@ -142,35 +142,21 @@ export class HuiViewBackgroundEditor extends LitElement {
`${(background.opacity ?? 100) / 100}`
);
const backgroundImage = this._currentBackgroundImage();
const backgroundImage =
typeof background.image === "object"
? background.image.media_content_id
: background.image;
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSourceWithCache(this.hass, backgroundImage).then(
(result) => {
// Discard if the image changed while resolving
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = result.url;
}
},
() => {
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = undefined;
}
}
);
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this._resolvedImage = result.url;
});
} else {
this._resolvedImage = backgroundImage;
}
}
}
private _currentBackgroundImage(): string | undefined {
const background = this._backgroundData(this.config);
return typeof background.image === "object"
? background.image.media_content_id
: background.image;
}
protected render() {
if (!this.hass) {
return nothing;
@@ -5,7 +5,7 @@ import type { HomeAssistant } from "../../../types";
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
import {
isMediaSourceContentId,
resolveMediaSourceWithCache,
resolveMediaSource,
} from "../../../data/media_source";
@customElement("hui-view-background")
@@ -21,37 +21,20 @@ export class HUIViewBackground extends LitElement {
return nothing;
}
private _getBackgroundImage(
background?: string | LovelaceViewBackgroundConfig
): string | undefined {
if (typeof background === "string") {
return background;
}
if (typeof background?.image === "object") {
return background.image.media_content_id;
}
return background?.image;
}
private _fetchMedia() {
const backgroundImage =
typeof this.background === "string"
? this.background
: typeof this.background?.image === "object"
? this.background.image.media_content_id
: this.background?.image;
private async _fetchMedia() {
const backgroundImage = this._getBackgroundImage(this.background);
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this.resolvedImage = result.url;
});
} else {
this.resolvedImage = undefined;
return;
}
let resolvedUrl: string | undefined;
try {
resolvedUrl = (
await resolveMediaSourceWithCache(this.hass, backgroundImage)
).url;
} catch {
resolvedUrl = undefined;
}
// Discard if the background changed while resolving
if (this._getBackgroundImage(this.background) === backgroundImage) {
this.resolvedImage = resolvedUrl;
}
}
@@ -90,7 +73,10 @@ export class HUIViewBackground extends LitElement {
background?: string | LovelaceViewBackgroundConfig
) {
if (typeof background === "object" && background.image) {
const image = this._getBackgroundImage(background) || "";
const image =
typeof background.image === "object"
? background.image.media_content_id || ""
: background.image;
if (isMediaSourceContentId(image) && !this.resolvedImage) {
return null;
}
+32 -49
View File
@@ -802,10 +802,8 @@
"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}",
"device_replaced_by_one": "Replaced by {device}",
"replace_update": "Update",
"replace_device": "Replace",
"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}",
@@ -926,24 +924,16 @@
"no_area": "No area",
"placeholder": "Select a device",
"unknown": "Unknown device selected",
"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",
"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",
"replaced_dialog": {
"title": "Replace device",
"description": "This device was replaced by multiple devices. Choose which one to use.",
"recommended": "Recommended"
}
},
"device-class-picker": {
"device_class": "Device class",
"add": "Add device class",
"no_device_classes": "No device classes available",
"no_match": "No device classes found for {term}"
},
"category-picker": {
"clear": "Clear",
"show_categories": "Show categories",
@@ -1684,9 +1674,10 @@
"person": "Edit person"
},
"details": "Details",
"context": "Context",
"entity": "Entity",
"floor": "Floor",
"entity_id": "Entity ID",
"copy_value": "Copy {label}: {value}",
"labels": "Labels",
"toggle_yaml_mode": "Toggle YAML mode",
"translated": "Translated",
@@ -5724,6 +5715,7 @@
"state": {
"label": "State",
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
"attribute_no_for": "A duration isn't available when matching on an attribute.",
"description": {
"picker": "Tests if an entity (or attribute) is in a specific state.",
"is": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n} is {states}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
@@ -6438,25 +6430,12 @@
"description_features": "Control home when away and integrate with Alexa and Google Assistant",
"login": {
"title": "Cloud login",
"landing_title": "[%key:ui::panel::config::cloud::account::onboarding::ha_cloud_title%]",
"hero_title": "Tools for your home, support for the Open Home",
"hero_lead": "The easy, secure, and private way to get more out of Home Assistant, and the simplest way to fund its development.",
"trial_note": "Try free for a month. No payment information required up front.",
"partner_note": "This service is run by our partner {nabu_casa_link}, a company founded by the founders of Home Assistant.",
"feature_remote_title": "Access from anywhere",
"feature_remote_body": "Securely and privately reach your Home Assistant when you're away from home. Zero router configuration, port forwarding, or complex VPNs.",
"feature_backup_title": "Backups kept safe",
"feature_backup_body": "Your latest Home Assistant backup, automatically stored somewhere safe and only accessible with your private key, ready to restore your full system with one click.",
"feature_voice_control_title": "Alexa and Google Home made simple",
"feature_voice_control_body": "Ease your transition to Home Assistant, use your Google and Alexa speakers to control every device. Set up in minutes, not hours.",
"feature_voice_quality_title": "Fast, accurate voice control",
"feature_voice_quality_body": "More languages and higher accuracy for your own private voice assistant powered by Home Assistant.",
"feature_companion_title": "Secure app connectivity",
"feature_companion_body": "The Home Assistant app on your mobile device forms a secure connection home, encrypting your location and sensor data.",
"feature_support_title": "Support Home Assistant",
"feature_support_body": "Your subscription directly funds the Open Home Foundation, keeping Home Assistant independent and free from ads or data harvesting.",
"introduction": "Home Assistant Cloud provides you with a secure remote connection to your instance while away from home. It also allows you to connect with cloud-only services: Amazon Alexa and Google Assistant.",
"introduction2": "This service is run by our partner ",
"introduction2a": ", a company founded by the founders of Home Assistant.",
"introduction3": "Home Assistant Cloud is a subscription service with a free one-month trial. No payment information necessary.",
"learn_more_link": "Learn more about Home Assistant Cloud",
"sign_in": "Sign in",
"sign_in_lead": "Sign in to your Nabu Casa account.",
"email": "Email",
"email_error_msg": "Invalid email",
"password": "Password",
@@ -6466,11 +6445,15 @@
"cancel": "Cancel",
"submit": "Submit",
"forgot_password": "Forgot password?",
"start_trial": "Start your free 1-month trial",
"trial_info": "No payment information necessary",
"alert_password_change_required": "You need to change your password before logging in.",
"alert_email_confirm_necessary": "You need to confirm your email before logging in.",
"alert_mfa_code_required": "You need to enter your two-factor authentication code.",
"alert_mfa_expired_or_not_started": "Multi-factor authentication expired, or not started. Please try again.",
"alert_totp_code_invalid": "Invalid two-factor authentication code."
"alert_totp_code_invalid": "Invalid two-factor authentication code.",
"cloud_pipeline_title": "Want to use Home Assistant Cloud for your voice assistant?",
"cloud_pipeline_text": "We created a new assistant for you, using the superior text-to-speech and speech-to-text engines from Home Assistant Cloud. Would you like to set this assistant as the preferred assistant?"
},
"forgot_password": {
"title": "Forgot password",
@@ -6482,9 +6465,17 @@
"check_your_email": "Check your email for instructions on how to reset your password."
},
"register": {
"headline": "Start free trial",
"title": "Register account",
"headline": "Start your free trial",
"information": "Create an account to start your free one-month trial with Home Assistant Cloud. No payment information necessary.",
"terms_notice": "By creating an account, you agree to our {terms} and {privacy_policy}.",
"information2": "The trial will give you access to all the benefits of Home Assistant Cloud, including:",
"feature_remote_control": "Control of Home Assistant away from home",
"feature_google_home": "Integration with Google Assistant",
"feature_amazon_alexa": "Integration with Amazon Alexa",
"feature_webhook_apps": "Easy integration with webhook-based apps like OwnTracks",
"information3": "This service is run by our partner ",
"information3a": ", a company founded by the founders of Home Assistant.",
"information4": "By registering an account, you agree to the following terms and conditions.",
"link_terms_conditions": "Terms and conditions",
"link_privacy_policy": "Privacy policy",
"create_account": "Create account",
@@ -6493,17 +6484,10 @@
"password": "Password",
"password_error_msg": "Passwords are at least 8 characters",
"start_trial": "Start trial",
"sign_in_instead": "Sign in instead",
"check_your_email": "Check your email",
"confirm_email_body": "Open the confirmation link in the email we just sent to {email}.",
"waiting_for_confirmation": "Waiting for confirmation. We'll sign you in automatically.",
"checking_confirmation": "Checking for your confirmation…",
"auto_login_failed": "We couldn't sign you in automatically. Confirm your email, then sign in.",
"nothing_arrived": "Nothing arrived? Check your spam folder, or {resend_link}.",
"resend_link": "resend the confirmation email",
"resend_confirm_email": "Resend confirmation email",
"clicked_confirm": "I opened the confirmation link",
"confirm_email": "Check the email we just sent to {email} and open the confirmation link to continue.",
"account_created": "Account created! Check your email for instructions on how to activate your account.",
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
},
"account": {
@@ -8403,10 +8387,7 @@
"status": "Status",
"version": "Version",
"data_rate": "Data rate",
"area": "Area",
"neighbor": "In range of each other",
"toggle_neighbors": "Toggle neighbor connections. Loading neighbor data temporarily turns off the Z-Wave adapter.",
"neighbors_error": "Failed to load neighbor connections"
"area": "Area"
},
"node_status": {
"0": "Unknown",
@@ -11773,6 +11754,8 @@
"cancel": "[%key:ui::panel::config::cloud::login::cancel%]",
"submit": "[%key:ui::panel::config::cloud::login::submit%]",
"forgot_password": "[%key:ui::panel::config::cloud::login::forgot_password%]",
"start_trial": "[%key:ui::panel::config::cloud::login::start_trial%]",
"trial_info": "[%key:ui::panel::config::cloud::login::trial_info%]",
"alert_password_change_required": "[%key:ui::panel::config::cloud::login::alert_password_change_required%]",
"alert_email_confirm_necessary": "[%key:ui::panel::config::cloud::login::alert_email_confirm_necessary%]",
"alert_mfa_code_required": "[%key:ui::panel::config::cloud::login::alert_mfa_code_required%]",
-8
View File
@@ -13,11 +13,3 @@ declare module "echarts/lib/util/states" {
declare module "echarts/lib/chart/sankey/SankeyView" {
export { default } from "echarts/types/src/chart/sankey/SankeyView";
}
declare module "echarts/lib/util/number" {
export * from "echarts/types/src/util/number";
}
declare module "echarts/lib/scale/helper" {
export * from "echarts/types/src/scale/helper";
}
-30
View File
@@ -17,24 +17,10 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
return points;
};
// The chart data modules break the line with a null value. A handful of them
// stands for an entity that went unavailable; a series that is mostly null
// stands for the climate heating dataset, which emits one per inactive state.
const withGaps = (
points: [number, number][],
isGap: (index: number) => boolean
): [number, number | null][] =>
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
const small = generatePoints(1, SCALES.small);
const medium = generatePoints(2, SCALES.medium);
const large = generatePoints(3, SCALES.large);
const largeObjects = large.map((value) => ({ value }));
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
const largeMostlyGaps = withGaps(
large,
(index) => Math.floor(index / 50) % 3 !== 0
);
describe("downSampleLineData", () => {
bench("min/max small (1k points)", () => {
@@ -68,20 +54,4 @@ describe("downSampleLineData", () => {
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large with a few gaps (100k points)",
() => {
downSampleLineData(largeFewGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large mostly gaps (100k points)",
() => {
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
});
-418
View File
@@ -1,418 +0,0 @@
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.
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",
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 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.
expect(url).toContain("/api/map_tiles/raster/{z}/{x}/{y}.png");
// 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 });
// 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");
});
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();
});
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();
});
});
+3 -151
View File
@@ -1,16 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
NavigateOptions,
UnsavedChangesGuard,
} from "../../src/common/navigate";
import {
canGoBack,
goBack,
navigate,
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
import type { NavigateOptions } from "../../src/common/navigate";
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
@@ -63,145 +54,6 @@ describe("navigate", () => {
});
});
describe("unsaved changes guard", () => {
const registeredGuards: UnsavedChangesGuard[] = [];
// Registering through this keeps afterEach able to clean up the module-level
// registry, which outlives the test that filled it.
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
registerUnsavedChangesGuard(guard);
registeredGuards.push(guard);
return guard;
};
const registerGuard = (isDirty: boolean, promptResult = true) =>
trackGuard({
isDirty: vi.fn(() => isDirty),
prompt: vi.fn(async () => promptResult),
});
// A guard whose prompt stays open until the test answers it.
const trackDeferredGuard = (isDirty: () => boolean) => {
let answer!: (value: boolean) => void;
const guard = trackGuard({
isDirty,
prompt: vi.fn(
() =>
new Promise<boolean>((resolve) => {
answer = resolve;
})
),
});
return { guard, answerPrompt: (value: boolean) => answer(value) };
};
beforeEach(() => {
setEntry("/config");
});
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
});
it("navigates without prompting when no guard is dirty", async () => {
const guard = registerGuard(false);
expect(await navigate("/config/areas")).toBe(true);
expect(window.location.pathname).toEqual("/config/areas");
expect(guard.prompt).not.toHaveBeenCalled();
});
it("prompts a dirty guard and navigates when confirmed", async () => {
const guard = registerGuard(true, true);
expect(await navigate("/config/areas")).toBe(true);
expect(guard.prompt).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/config/areas");
});
it("leaves history untouched when the prompt is declined", async () => {
const guard = registerGuard(true, false);
const listener = vi.fn();
window.addEventListener("location-changed", listener);
expect(await navigate("/config/areas")).toBe(false);
window.removeEventListener("location-changed", listener);
expect(guard.prompt).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/config");
expect(listener).not.toHaveBeenCalled();
});
it("does not prompt when navigating to the current path", async () => {
const guard = registerGuard(true, false);
expect(await navigate("/config")).toBe(true);
expect(guard.prompt).not.toHaveBeenCalled();
});
it("skips a pending prompt once no guard is dirty anymore", async () => {
let dirty = true;
const { guard, answerPrompt } = trackDeferredGuard(() => dirty);
const first = navigate("/config/areas");
dirty = false;
expect(await navigate("/config/devices/dashboard")).toBe(true);
expect(window.location.pathname).toEqual("/config/devices/dashboard");
expect(guard.prompt).toHaveBeenCalledOnce();
answerPrompt(true);
await first;
});
it("drops a navigation superseded while its prompt was open", async () => {
let dirty = true;
const { answerPrompt } = trackDeferredGuard(() => dirty);
const superseded = navigate("/config/areas");
dirty = false;
await navigate("/config/devices/dashboard");
answerPrompt(true);
expect(await superseded).toBe(false);
expect(window.location.pathname).toEqual("/config/devices/dashboard");
});
it("shares one pending prompt between concurrent navigations", async () => {
const { guard, answerPrompt } = trackDeferredGuard(() => true);
const first = navigate("/config/areas");
const second = navigate("/config/devices/dashboard");
answerPrompt(true);
await Promise.all([first, second]);
expect(guard.prompt).toHaveBeenCalledOnce();
});
it("stops prompting once the guard is unregistered", async () => {
const guard = registerGuard(true, false);
unregisterUnsavedChangesGuard(guard);
expect(await navigate("/config/areas")).toBe(true);
expect(guard.prompt).not.toHaveBeenCalled();
});
it("does not prompt for goBack's fallback navigation", async () => {
const guard = registerGuard(true, false);
await goBack("/config/cloud/account");
expect(window.location.pathname).toEqual("/config/cloud/account");
expect(guard.prompt).not.toHaveBeenCalled();
});
});
describe("goBack", () => {
beforeEach(() => {
setEntry("/config/cloud/remote");
@@ -1,106 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { shareInFlightRequest } from "../../../src/common/util/share-in-flight-request";
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("shareInFlightRequest", () => {
it("shares an in-flight request for the same owner and key", async () => {
const owner = {};
const request = deferred<number>();
const fetcher = vi.fn(() => request.promise);
const first = shareInFlightRequest(owner, "resource:a", fetcher);
const second = shareInFlightRequest(owner, "resource:a", fetcher);
expect(first).toBe(second);
expect(fetcher).toHaveBeenCalledTimes(1);
request.resolve(42);
await expect(first).resolves.toBe(42);
await expect(second).resolves.toBe(42);
});
it("does not share requests with different keys", async () => {
const owner = {};
const fetcherA = vi.fn(async () => "a");
const fetcherB = vi.fn(async () => "b");
await Promise.all([
shareInFlightRequest(owner, "resource:a", fetcherA),
shareInFlightRequest(owner, "resource:b", fetcherB),
]);
expect(fetcherA).toHaveBeenCalledTimes(1);
expect(fetcherB).toHaveBeenCalledTimes(1);
});
it("does not share requests between different owners", async () => {
const firstOwner = {};
const secondOwner = {};
const firstFetcher = vi.fn(async () => "first");
const secondFetcher = vi.fn(async () => "second");
await Promise.all([
shareInFlightRequest(firstOwner, "resource:a", firstFetcher),
shareInFlightRequest(secondOwner, "resource:a", secondFetcher),
]);
expect(firstFetcher).toHaveBeenCalledTimes(1);
expect(secondFetcher).toHaveBeenCalledTimes(1);
});
it("forgets a request after it resolves", async () => {
const owner = {};
const fetcher = vi.fn(async () => 42);
await shareInFlightRequest(owner, "resource:a", fetcher);
await shareInFlightRequest(owner, "resource:a", fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("forgets a request after it rejects", async () => {
const owner = {};
const fetcher = vi
.fn<() => Promise<number>>()
.mockRejectedValueOnce(new Error("failed"))
.mockResolvedValueOnce(42);
await expect(
shareInFlightRequest(owner, "resource:a", fetcher)
).rejects.toThrow("failed");
await expect(
shareInFlightRequest(owner, "resource:a", fetcher)
).resolves.toBe(42);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("deep-freezes resolved results", async () => {
const owner = {};
const result = { items: ["a"] };
const fetcher = vi.fn(async () => result);
const shared = await shareInFlightRequest(owner, "resource:a", fetcher);
expect(shared).toBe(result);
expect(Object.isFrozen(shared)).toBe(true);
expect(Object.isFrozen((shared as typeof result).items)).toBe(true);
expect(() => {
(shared as typeof result).items.push("b");
}).toThrow();
});
});

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