mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-28 01:47:26 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb4e925321 | ||
|
|
69c1735ce6 | ||
|
|
be6461ed9a | ||
|
|
7f86074e07 | ||
|
|
4b9e47079c | ||
|
|
e3b9c6e143 | ||
|
|
221f184cb1 | ||
|
|
27cbbdb83f | ||
|
|
337e88560e | ||
|
|
9c96c56558 | ||
|
|
27e0c4492c | ||
|
|
80889c3cea | ||
|
|
cbbaf51e75 | ||
|
|
9778a54305 | ||
|
|
b9125537c0 | ||
|
|
fd6017a963 | ||
|
|
e0f5d6d6b3 | ||
|
|
193ab1b424 | ||
|
|
4a3b6db411 | ||
|
|
46271d0435 | ||
|
|
d4bc02c86b | ||
|
|
a12ae9a8ed | ||
|
|
6095787294 | ||
|
|
127702b96f | ||
|
|
56a6c5d9fb | ||
|
|
3b686dc3b9 | ||
|
|
c5e8dea750 | ||
|
|
29cd46eb9e | ||
|
|
92b3081896 | ||
|
|
527319ab5e | ||
|
|
8d3877c388 | ||
|
|
66b9fbd4bf |
@@ -74,3 +74,4 @@ test/e2e/app/dist/
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
@@ -89,7 +90,7 @@ function copyQrScannerWorker(staticDir) {
|
||||
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
|
||||
}
|
||||
|
||||
function copyMapPanel(staticDir) {
|
||||
async function copyMapPanel(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
copyFileDir(
|
||||
npmPath("leaflet/dist/leaflet.css"),
|
||||
@@ -103,6 +104,10 @@ function copyMapPanel(staticDir) {
|
||||
npmPath("leaflet/dist/images"),
|
||||
staticPath("images/leaflet/images/")
|
||||
);
|
||||
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
@@ -139,7 +144,7 @@ gulp.task("copy-static-app", async () => {
|
||||
copyMdiIcons(staticDir);
|
||||
|
||||
// Panel assets
|
||||
copyMapPanel(staticDir);
|
||||
await copyMapPanel(staticDir);
|
||||
|
||||
// Qr Scanner assets
|
||||
copyZXingWasm(staticDir);
|
||||
@@ -155,7 +160,7 @@ gulp.task("copy-static-demo", async () => {
|
||||
// Copy demo static files
|
||||
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
|
||||
copyPolyfills(paths.demo_output_static);
|
||||
copyMapPanel(paths.demo_output_static);
|
||||
await copyMapPanel(paths.demo_output_static);
|
||||
copyFonts(paths.demo_output_static);
|
||||
copyTranslations(paths.demo_output_static);
|
||||
copyLocaleData(paths.demo_output_static);
|
||||
@@ -168,7 +173,7 @@ gulp.task("copy-static-cast", async () => {
|
||||
// Copy cast static files
|
||||
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
|
||||
copyPolyfills(paths.cast_output_static);
|
||||
copyMapPanel(paths.cast_output_static);
|
||||
await copyMapPanel(paths.cast_output_static);
|
||||
copyFonts(paths.cast_output_static);
|
||||
copyTranslations(paths.cast_output_static);
|
||||
copyLocaleData(paths.cast_output_static);
|
||||
@@ -184,7 +189,7 @@ gulp.task("copy-static-gallery", async () => {
|
||||
paths.gallery_output_root
|
||||
);
|
||||
|
||||
copyMapPanel(paths.gallery_output_static);
|
||||
await copyMapPanel(paths.gallery_output_static);
|
||||
copyFonts(paths.gallery_output_static);
|
||||
copyTranslations(paths.gallery_output_static);
|
||||
copyLocaleData(paths.gallery_output_static);
|
||||
@@ -215,7 +220,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
await copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
|
||||
@@ -14,6 +14,7 @@ import "./gen-icons-json.js";
|
||||
import "./gen-sensor-entity-constants.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./map-assets.js";
|
||||
import "./rspack.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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;
|
||||
+4
-1
@@ -75,6 +75,7 @@
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.5.0",
|
||||
@@ -115,6 +116,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.10",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -170,6 +172,7 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@versatiles/style": "5.13.1",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
@@ -203,7 +206,7 @@
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"minify-literals": "2.2.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
|
||||
@@ -27,6 +27,7 @@ const ALLOWED_LICENSES = new Set([
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import type { Map, TileLayer } from "leaflet";
|
||||
import type { Map } from "leaflet";
|
||||
import type { MapBaseLayer } from "../map/base-layer";
|
||||
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
|
||||
|
||||
// Sets up a Leaflet map on the provided DOM element
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletModuleType = typeof import("leaflet");
|
||||
|
||||
export interface LeafletMapSetup {
|
||||
map: Map;
|
||||
leaflet: LeafletModuleType;
|
||||
baseLayer: MapBaseLayer;
|
||||
}
|
||||
|
||||
export const setupLeafletMap = async (
|
||||
mapElement: HTMLElement,
|
||||
initialView?: { latitude: number; longitude: number; zoom?: number }
|
||||
): Promise<[Map, LeafletModuleType, TileLayer]> => {
|
||||
initialView?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
darkMode?: boolean;
|
||||
token?: string;
|
||||
}
|
||||
): Promise<LeafletMapSetup> => {
|
||||
if (!mapElement.parentNode) {
|
||||
throw new Error("Cannot setup Leaflet map on disconnected element");
|
||||
}
|
||||
@@ -17,7 +31,11 @@ export const setupLeafletMap = async (
|
||||
|
||||
await import("leaflet.markercluster");
|
||||
|
||||
const map = Leaflet.map(mapElement);
|
||||
const map = Leaflet.map(mapElement, {
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
});
|
||||
map.attributionControl.setPrefix("");
|
||||
const style = document.createElement("link");
|
||||
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
|
||||
style.setAttribute("rel", "stylesheet");
|
||||
@@ -38,21 +56,14 @@ export const setupLeafletMap = async (
|
||||
);
|
||||
}
|
||||
|
||||
const tileLayer = createTileLayer(Leaflet).addTo(map);
|
||||
|
||||
return [map, Leaflet, tileLayer];
|
||||
};
|
||||
|
||||
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
|
||||
leaflet.tileLayer(
|
||||
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
|
||||
leaflet.Browser.retina ? "@2x.png" : ".png"
|
||||
}`,
|
||||
{
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: "abcd",
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
}
|
||||
// The base layer adds itself: the vector layer only builds its MapLibre map
|
||||
// once it is on the map, and that failing has to fall back to raster.
|
||||
const baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
initialView?.darkMode ?? false,
|
||||
initialView?.token
|
||||
);
|
||||
|
||||
return { map, leaflet: Leaflet, baseLayer };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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,
|
||||
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 =
|
||||
'© <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;
|
||||
|
||||
// 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;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
return {
|
||||
setDarkMode: (newDarkMode: boolean) => {
|
||||
if (!vector || newDarkMode === requestedDarkMode) {
|
||||
return;
|
||||
}
|
||||
requestedDarkMode = newDarkMode;
|
||||
const request = ++latestRequest;
|
||||
|
||||
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
|
||||
.then((style) => {
|
||||
if (request === latestRequest) {
|
||||
appliedDarkMode = newDarkMode;
|
||||
layer.getMaplibreMap()?.setStyle(style);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (request === latestRequest) {
|
||||
requestedDarkMode = appliedDarkMode;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createRasterLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
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;
|
||||
});
|
||||
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);
|
||||
};
|
||||
+85
-2
@@ -80,6 +80,67 @@ 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
|
||||
@@ -91,7 +152,7 @@ const buildHistoryState = (
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
@@ -127,9 +188,28 @@ export const navigate = 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
|
||||
@@ -142,6 +222,9 @@ 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());
|
||||
@@ -156,5 +239,5 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
await performNavigation(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
};
|
||||
@@ -3,6 +3,27 @@ import type { TooltipPositionCallback } from "echarts/types/dist/shared";
|
||||
export const TOOLTIP_GAP_PX = 12;
|
||||
export const TOOLTIP_TOP_OFFSET_PX = 10;
|
||||
|
||||
const offsetFromCursor = (
|
||||
cursorX: number,
|
||||
dom: unknown,
|
||||
viewW: number,
|
||||
tipW: number
|
||||
) => {
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
return Math.max(0, Math.min(x, viewW - tipW));
|
||||
};
|
||||
|
||||
/**
|
||||
* Pins the tooltip near the top of the chart and offsets it horizontally
|
||||
* from the cursor so it never covers the data point being inspected.
|
||||
@@ -20,21 +41,29 @@ export const sideTooltipPosition: TooltipPositionCallback = (
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
x = Math.max(0, Math.min(x, viewW - tipW));
|
||||
|
||||
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
|
||||
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
/**
|
||||
* Offsets the tooltip horizontally from the cursor and keeps it level with it.
|
||||
* For item-trigger tooltips where the cursor's row is what the tooltip shows.
|
||||
*/
|
||||
export const itemTooltipPosition: TooltipPositionCallback = (
|
||||
point,
|
||||
_params,
|
||||
dom,
|
||||
_rect,
|
||||
size
|
||||
) => {
|
||||
const [cursorX, cursorY] = point;
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
|
||||
const y = Math.max(0, Math.min(cursorY - tipH / 2, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
@@ -328,6 +328,7 @@ 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,4 +1,3 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -13,7 +12,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { itemTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
@@ -24,7 +23,6 @@ import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
const GRID_BOTTOM = 30;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
@@ -43,10 +41,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw each row's name above its bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -69,13 +63,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
private _width = 0;
|
||||
|
||||
private _resize = new ResizeController(this, {
|
||||
skipInitial: true,
|
||||
callback: (entries) => entries[0]?.contentRect.width,
|
||||
});
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
protected render() {
|
||||
@@ -83,7 +70,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
|
||||
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -193,19 +180,13 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
this._generateData();
|
||||
}
|
||||
|
||||
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
|
||||
const widthChanged = width !== this._width;
|
||||
this._width = width;
|
||||
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth") ||
|
||||
widthChanged
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -215,22 +196,14 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
// Keeps the plot aligned with the line charts sharing the y-axis padding.
|
||||
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
|
||||
// A zero width hides the labels instead of truncating them.
|
||||
const insideLabelWidth = this._width
|
||||
? Math.max(0, this._width - plotPadding - labelMargin)
|
||||
: undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -254,56 +227,41 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "bottom",
|
||||
width: insideLabelWidth,
|
||||
overflow: "truncate",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: insideLabels ? 20 : 10,
|
||||
top: 10,
|
||||
bottom: GRID_BOTTOM,
|
||||
left: rtl ? 1 : plotPadding,
|
||||
right: rtl ? plotPadding : 1,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
position: sideTooltipPosition,
|
||||
position: itemTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -443,10 +401,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-chart-base {
|
||||
--chart-max-height: none;
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw timeline row names above their bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -231,7 +227,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -429,12 +424,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Names inside the plot sit close to the chart above them, so the groups
|
||||
need more room between them to stay apart. */
|
||||
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -446,6 +446,7 @@ 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,
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
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. 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.
|
||||
// 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.
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -22,13 +41,7 @@ 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;
|
||||
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);
|
||||
return getPrecision(nice(range / SPLIT_NUMBER, true));
|
||||
}
|
||||
|
||||
interface YAxisExtentValues {
|
||||
@@ -44,34 +57,116 @@ const resolveYAxisBound = (
|
||||
values: YAxisExtentValues
|
||||
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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, onFractionDigits } = options;
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
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 resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
const bounds = resolveBounds(values);
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
computeYAxisFractionDigits(
|
||||
bounds.min ?? values.min - bounds.gap,
|
||||
bounds.max ?? values.max + bounds.gap,
|
||||
includeZero
|
||||
)
|
||||
);
|
||||
return resolvedMin;
|
||||
return bounds.min;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
max: (values) => resolveBounds(values).max,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { fullEntitiesContext } from "../../data/context";
|
||||
import type { DeviceAutomation } from "../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationsEqual,
|
||||
deviceAutomationsSimilar,
|
||||
sortDeviceAutomations,
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
@@ -180,12 +179,15 @@ export abstract class HaDeviceAutomationPicker<
|
||||
(a, idx) => value === `${a.device_id}_${idx}`
|
||||
);
|
||||
|
||||
const text = automation
|
||||
const described =
|
||||
automation ?? (this.value?.domain ? this.value : undefined);
|
||||
|
||||
const text = described
|
||||
? this._localizeDeviceAutomation(
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this._entityReg,
|
||||
automation
|
||||
described
|
||||
)
|
||||
: value === NO_AUTOMATION_KEY
|
||||
? this.NO_AUTOMATION_TEXT
|
||||
@@ -195,29 +197,24 @@ export abstract class HaDeviceAutomationPicker<
|
||||
};
|
||||
|
||||
private async _updateDeviceInfo() {
|
||||
// Asking a removed device for its automations fails rather than returning
|
||||
// an empty list.
|
||||
this._automations = this.deviceId
|
||||
? (
|
||||
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
|
||||
await this._fetchDeviceAutomations(
|
||||
this.hass.callWS,
|
||||
this.deviceId
|
||||
).catch(() => [] as T[])
|
||||
).sort(sortDeviceAutomations)
|
||||
: // No device, clear the list of automations
|
||||
[];
|
||||
|
||||
// If there is no value, or if we have changed the device ID, reset the
|
||||
// value. When the device changed (for example after replacing a removed
|
||||
// device), try to keep the same automation type/subtype on the new device
|
||||
// before falling back to the first available automation.
|
||||
// If there is no value, or if we have changed the device ID, reset the value.
|
||||
if (!this.value || this.value.device_id !== this.deviceId) {
|
||||
const equivalent =
|
||||
this.value && this.deviceId
|
||||
? this._automations.find((automation) =>
|
||||
deviceAutomationsSimilar(automation, this.value!)
|
||||
)
|
||||
: undefined;
|
||||
this._setValue(
|
||||
equivalent ||
|
||||
(this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId))
|
||||
this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId)
|
||||
);
|
||||
}
|
||||
this._renderEmpty = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import { mdiSwapHorizontal } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-alert";
|
||||
@@ -104,6 +105,14 @@ export class HaDevicePicker extends LitElement {
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
/**
|
||||
* The split devices that can actually replace the current value, when the
|
||||
* caller knows better than this picker. Narrows the replacement candidates,
|
||||
* so the user is only asked to choose when there is a real choice.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public replacementDeviceIds?: string[];
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
@@ -187,7 +196,8 @@ export class HaDevicePicker extends LitElement {
|
||||
value: string | undefined,
|
||||
_devices: HomeAssistant["devices"],
|
||||
compositeSplits: DeviceCompositeSplits | undefined,
|
||||
items: (DevicePickerItem | string)[]
|
||||
items: (DevicePickerItem | string)[],
|
||||
replacementDeviceIds: string[] | undefined
|
||||
) => {
|
||||
if (!value || !compositeSplits || this.hass.devices[value]) {
|
||||
return undefined;
|
||||
@@ -203,7 +213,11 @@ export class HaDevicePicker extends LitElement {
|
||||
.filter((item): item is DevicePickerItem => typeof item !== "string")
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
|
||||
const candidates = split.split_ids.filter(
|
||||
(id) =>
|
||||
selectableIds.has(id) &&
|
||||
(!replacementDeviceIds || replacementDeviceIds.includes(id))
|
||||
);
|
||||
return { candidates, primaryId: split.primary_id };
|
||||
}
|
||||
);
|
||||
@@ -250,26 +264,26 @@ export class HaDevicePicker extends LitElement {
|
||||
};
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
replacementName: string | undefined
|
||||
) =>
|
||||
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
|
||||
(value: string) => {
|
||||
const deviceId = value;
|
||||
const device = this.hass.devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
// When the device was replaced and a replacement is available, show
|
||||
// the replacement device's name. Otherwise fall back to the normal
|
||||
// "not found" display of the raw id.
|
||||
if (replacementName) {
|
||||
// The removed device has no name left to show, so say what happened
|
||||
// to it instead. The alert below names the replacements. Without a
|
||||
// replacement, fall back to the normal "not found" display.
|
||||
if (isReplaced) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
style="color: var(--warning-color)"
|
||||
.path=${mdiAlertOutline}
|
||||
.path=${mdiSwapHorizontal}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${replacementName}</span>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced"
|
||||
)}</span
|
||||
>
|
||||
`;
|
||||
}
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
@@ -399,31 +413,23 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems()
|
||||
this._getItems(),
|
||||
this.replacementDeviceIds
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Only treat the value as "replaced" when there is an available
|
||||
// replacement device; otherwise fall back to normal "not found" behavior.
|
||||
const canReplace = !!replacement?.candidates.length;
|
||||
const replacementName = canReplace
|
||||
? computeDeviceName(
|
||||
this.hass.devices[
|
||||
replacement!.primaryId &&
|
||||
replacement!.candidates.includes(replacement!.primaryId)
|
||||
? replacement!.primaryId
|
||||
: replacement!.candidates[0]
|
||||
]
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const valueRenderer = this._valueRenderer(
|
||||
this._configEntryLookup,
|
||||
replacementName
|
||||
canReplace
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.noUnknownState=${canReplace}
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.disabled=${this.disabled}
|
||||
@@ -443,14 +449,9 @@ export class HaDevicePicker extends LitElement {
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
.unknownItemText=${
|
||||
replacement?.candidates.length
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_count",
|
||||
{ count: replacement.candidates.length }
|
||||
)
|
||||
: this.hass.localize("ui.components.device-picker.unknown")
|
||||
}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.device-picker.unknown"
|
||||
)}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
@@ -464,30 +465,52 @@ export class HaDevicePicker extends LitElement {
|
||||
}) {
|
||||
const { candidates } = replacement;
|
||||
|
||||
const replacementName =
|
||||
candidates.length === 1
|
||||
? computeDeviceName(this.hass.devices[candidates[0]])
|
||||
: undefined;
|
||||
// The split devices all inherit the composite's name, so the integration is
|
||||
// what tells them apart.
|
||||
const replacementDevice =
|
||||
candidates.length === 1 ? this.hass.devices[candidates[0]] : undefined;
|
||||
const replacementName = replacementDevice
|
||||
? computeDeviceName(replacementDevice)
|
||||
: undefined;
|
||||
const replacementDomain = replacementDevice?.primary_config_entry
|
||||
? this._configEntryLookup[replacementDevice.primary_config_entry]?.domain
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-alert alert-type="warning">
|
||||
${
|
||||
replacementName
|
||||
replacementName && replacementDomain
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
"ui.components.device-picker.device_replaced_by_one_integration",
|
||||
{
|
||||
device: replacementName,
|
||||
integration: domainToName(
|
||||
this.hass.localize,
|
||||
replacementDomain
|
||||
),
|
||||
}
|
||||
)
|
||||
: replacementName
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
)
|
||||
}
|
||||
<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
variant="warning"
|
||||
@click=${this._handleReplace}
|
||||
>
|
||||
${this.hass.localize("ui.components.device-picker.replace_device")}
|
||||
${
|
||||
candidates.length === 1
|
||||
? this.hass.localize("ui.components.device-picker.replace_update")
|
||||
: this.hass.localize("ui.components.device-picker.replace_choose")
|
||||
}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`;
|
||||
@@ -498,7 +521,8 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems()
|
||||
this._getItems(),
|
||||
this.replacementDeviceIds
|
||||
);
|
||||
if (!replacement?.candidates.length) {
|
||||
return;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
|
||||
static styles = css`
|
||||
.issue-type {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
padding: var(--ha-alert-padding, 8px);
|
||||
display: flex;
|
||||
}
|
||||
.icon {
|
||||
|
||||
@@ -111,6 +111,11 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
|
||||
|
||||
// Skip the "unknown value" highlight and note for a value that is not in the
|
||||
// list but that the value renderer presents on its own.
|
||||
@property({ type: Boolean, attribute: "no-unknown-state" })
|
||||
public noUnknownState = false;
|
||||
|
||||
@query(".container") private _containerElement?: HTMLDivElement;
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
@@ -148,7 +153,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
private _unsubscribeTinyKeys?: () => void;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("value")) {
|
||||
if (
|
||||
changedProperties.has("value") ||
|
||||
changedProperties.has("noUnknownState")
|
||||
) {
|
||||
this._setUnknownValue();
|
||||
}
|
||||
}
|
||||
@@ -287,6 +295,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
private _setUnknownValue = () => {
|
||||
const items = this.getItems();
|
||||
if (
|
||||
this.noUnknownState ||
|
||||
this.allowCustomValue ||
|
||||
this.value === undefined ||
|
||||
this.value === null ||
|
||||
|
||||
@@ -25,9 +25,11 @@ import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
|
||||
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
|
||||
import type { MapBaseLayer } from "../../common/map/base-layer";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import { DecoratedMarker } from "../../common/map/decorated_marker";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
@@ -160,6 +162,8 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private Leaflet?: LeafletModuleType;
|
||||
|
||||
private _baseLayer?: MapBaseLayer;
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _mapItems: (Marker | Circle)[] = [];
|
||||
@@ -211,6 +215,7 @@ export class HaMap extends ReactiveElement {
|
||||
this.leafletMap.remove();
|
||||
this.leafletMap = undefined;
|
||||
this.Leaflet = undefined;
|
||||
this._baseLayer = undefined;
|
||||
}
|
||||
|
||||
// the control went away with the map, so don't hold on to it
|
||||
@@ -308,6 +313,7 @@ export class HaMap extends ReactiveElement {
|
||||
map.classList.toggle("dark", this._darkMode);
|
||||
map.classList.toggle("forced-dark", this.themeMode === "dark");
|
||||
map.classList.toggle("forced-light", this.themeMode === "light");
|
||||
this._baseLayer?.setDarkMode(this._darkMode);
|
||||
}
|
||||
|
||||
private _loading = false;
|
||||
@@ -322,11 +328,31 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
this._loading = true;
|
||||
try {
|
||||
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
|
||||
// The tiles are proxied by core behind a token, so nothing loads without
|
||||
// one. A host that provides no connection, or a backend without the
|
||||
// proxy, leaves the map without tiles rather than failing to set up.
|
||||
const token = this._connection
|
||||
? await ensureMapTilesToken(this._connection.connection)
|
||||
: undefined;
|
||||
|
||||
const setup = await setupLeafletMap(map, {
|
||||
latitude: this._config?.latitude ?? 52.3731339,
|
||||
longitude: this._config?.longitude ?? 4.8903147,
|
||||
zoom: this.zoom,
|
||||
darkMode: this._darkMode,
|
||||
token,
|
||||
});
|
||||
// Setting up fetches a style, so the element can be gone by now.
|
||||
// `disconnectedCallback` had no map to tear down, and keeping this one
|
||||
// would leave a live map - and its WebGL context - on a detached host,
|
||||
// and its container too initialized to set up again on reconnect.
|
||||
if (!this.isConnected) {
|
||||
setup.map.remove();
|
||||
return;
|
||||
}
|
||||
this.leafletMap = setup.map;
|
||||
this.Leaflet = setup.leaflet;
|
||||
this._baseLayer = setup.baseLayer;
|
||||
this._updateMapStyle();
|
||||
this.leafletMap.on("click", (ev) => {
|
||||
if (this._clickCount === 0) {
|
||||
@@ -891,9 +917,22 @@ export class HaMap extends ReactiveElement {
|
||||
cursor: -moz-grabbing;
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
.leaflet-tile-pane {
|
||||
/* Only the raster fallback is inverted for dark mode, the vector style
|
||||
ships its own dark cartography. */
|
||||
.leaflet-tile-pane .leaflet-tile {
|
||||
filter: var(--map-filter);
|
||||
}
|
||||
/* The only two rules the MapLibre canvas needs from its stylesheet, the
|
||||
rest of it styles controls and popups we do not render. */
|
||||
.maplibregl-map {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.maplibregl-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.dark .leaflet-bar a {
|
||||
background-color: #1c1c1c;
|
||||
color: #ffffff;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
mdiMinusBox,
|
||||
mdiSwapHorizontal,
|
||||
mdiTextureBox,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
@@ -177,36 +178,51 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
|
||||
: this.type === "entity"
|
||||
? html`
|
||||
<ha-state-icon
|
||||
.stateObj=${
|
||||
stateObject ||
|
||||
({
|
||||
entity_id: this.itemId,
|
||||
attributes: {},
|
||||
} as HassEntity)
|
||||
}
|
||||
>
|
||||
</ha-state-icon>
|
||||
`
|
||||
: nothing
|
||||
: canMigrate
|
||||
? html`<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
|
||||
: this.type === "entity"
|
||||
? html`
|
||||
<ha-state-icon
|
||||
.stateObj=${
|
||||
stateObject ||
|
||||
({
|
||||
entity_id: this.itemId,
|
||||
attributes: {},
|
||||
} as HassEntity)
|
||||
}
|
||||
>
|
||||
</ha-state-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
|
||||
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
|
||||
<span slot="headline"
|
||||
>${
|
||||
canMigrate
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced_headline"
|
||||
)
|
||||
: name
|
||||
}</span
|
||||
>
|
||||
${
|
||||
notFound || (context && !this.hideContext)
|
||||
? html`<span slot="supporting-text"
|
||||
>${
|
||||
notFound
|
||||
? canMigrate
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced",
|
||||
{ count: replacement!.candidates.length }
|
||||
)
|
||||
? replacement!.candidates.length === 1 && replacement!.name
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced_by_one",
|
||||
{ device: replacement!.name }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced",
|
||||
{ count: replacement!.candidates.length }
|
||||
)
|
||||
: this.hass.localize(
|
||||
`ui.components.target-picker.${this.type}_not_found`
|
||||
)
|
||||
@@ -256,7 +272,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
@click=${this._migrate}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.replace_device"
|
||||
"ui.components.target-picker.replace_update"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
@@ -548,75 +564,73 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.primaryEntitiesOnly
|
||||
);
|
||||
|
||||
let referencedAreas = entries.referenced_areas;
|
||||
const hiddenAreaIds: string[] = [];
|
||||
if (this.type === "floor" || this.type === "label") {
|
||||
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);
|
||||
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) {
|
||||
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"
|
||||
) {
|
||||
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);
|
||||
referencedDevices = referencedDevices.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;
|
||||
});
|
||||
}
|
||||
|
||||
entries.referenced_entities = entries.referenced_entities.filter(
|
||||
const referencedEntities = entries.referenced_entities.filter(
|
||||
(entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
// Core can reference entities that are absent from the display
|
||||
@@ -631,9 +645,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
(this.type === "area" && entity.area_id === this.itemId) ||
|
||||
(this.type === "floor" &&
|
||||
entity.area_id &&
|
||||
entries.referenced_areas.includes(entity.area_id)) ||
|
||||
referencedAreas.includes(entity.area_id)) ||
|
||||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
|
||||
entries.referenced_devices.includes(entity.device_id || "")
|
||||
referencedDevices.includes(entity.device_id || "")
|
||||
) {
|
||||
return entityRegMeetsFilter(
|
||||
entity,
|
||||
@@ -648,7 +662,12 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
this._entries = entries;
|
||||
this._entries = {
|
||||
...entries,
|
||||
referenced_areas: referencedAreas,
|
||||
referenced_devices: referencedDevices,
|
||||
referenced_entities: referencedEntities,
|
||||
};
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to extract target", e);
|
||||
|
||||
@@ -480,6 +480,40 @@ export const migrateAutomationConfig = <
|
||||
return config;
|
||||
};
|
||||
|
||||
// The fields of the row holding a trigger, condition or action, as opposed to
|
||||
// the configuration of its type. The UI editors of some types build their value
|
||||
// from scratch, so these have to be carried over explicitly.
|
||||
export const TRIGGER_ROW_CONFIG_KEYS = [
|
||||
"alias",
|
||||
"note",
|
||||
"id",
|
||||
"enabled",
|
||||
"variables",
|
||||
] as const;
|
||||
|
||||
export const CONDITION_ROW_CONFIG_KEYS = ["alias", "note", "enabled"] as const;
|
||||
|
||||
export const ACTION_ROW_CONFIG_KEYS = [
|
||||
"alias",
|
||||
"note",
|
||||
"enabled",
|
||||
"continue_on_error",
|
||||
] as const;
|
||||
|
||||
export const pickRowConfig = <T extends object>(
|
||||
row: T,
|
||||
keys: readonly string[]
|
||||
): Partial<T> => {
|
||||
const source = row as Record<string, unknown>;
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (key in source) {
|
||||
config[key] = source[key];
|
||||
}
|
||||
}
|
||||
return config as Partial<T>;
|
||||
};
|
||||
|
||||
export const migrateAutomationTrigger = (
|
||||
trigger: Trigger | Trigger[],
|
||||
report?: AutomationMigrationReport
|
||||
|
||||
@@ -182,26 +182,69 @@ export const deviceAutomationEditorMode = (
|
||||
: "unknown-device";
|
||||
};
|
||||
|
||||
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
|
||||
// automation can be matched to the equivalent one on a different device (for
|
||||
// example when a referenced device was replaced by a split device).
|
||||
export const deviceAutomationsSimilar = (
|
||||
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
|
||||
deviceAutomationIdentifiers
|
||||
.filter((property) => property !== "device_id" && property !== "entity_id")
|
||||
.every((property) => Object.is(a[property], b[property]));
|
||||
|
||||
// An entity can be referenced by its registry id or by its entity id, and the
|
||||
// two sides do not have to agree.
|
||||
const deviceAutomationsSameEntity = (
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
a: DeviceAutomation,
|
||||
b: DeviceAutomation
|
||||
) => {
|
||||
if (typeof a !== typeof b) {
|
||||
if (!a.entity_id && !b.entity_id) {
|
||||
return true;
|
||||
}
|
||||
if (!a.entity_id || !b.entity_id) {
|
||||
return false;
|
||||
}
|
||||
return deviceAutomationIdentifiers
|
||||
.filter((property) => property !== "device_id" && property !== "entity_id")
|
||||
.every((property) => {
|
||||
const inA = property in a;
|
||||
const inB = property in b;
|
||||
if (!inA && !inB) {
|
||||
return true;
|
||||
}
|
||||
return Object.is(a[property], b[property]);
|
||||
});
|
||||
return (
|
||||
a.entity_id === b.entity_id ||
|
||||
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
|
||||
);
|
||||
};
|
||||
|
||||
// A device exposes the same automation type once per entity, so the same type
|
||||
// on another entity is a different automation, not an equivalent one.
|
||||
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automations: T[],
|
||||
automation: DeviceAutomation
|
||||
): T | undefined =>
|
||||
automations.find(
|
||||
(candidate) =>
|
||||
deviceAutomationsSameType(candidate, automation) &&
|
||||
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
|
||||
);
|
||||
|
||||
// Among the split devices that replaced a removed device, the ones that offer
|
||||
// the given automation. Nothing in the registry says which of them took it over,
|
||||
// so each candidate has to be asked.
|
||||
export const fetchReplacementDevices = async <T extends DeviceAutomation>(
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automation: DeviceAutomation,
|
||||
compositeSplits: DeviceCompositeSplits,
|
||||
fetchDeviceAutomations: (callWS: CallWS, deviceId: string) => Promise<T[]>
|
||||
): Promise<string[]> => {
|
||||
const candidates =
|
||||
compositeSplits[automation.device_id]?.split_ids.filter(
|
||||
(id) => id in hass.devices
|
||||
) ?? [];
|
||||
const automationsPerCandidate = await Promise.all(
|
||||
candidates.map((id) =>
|
||||
fetchDeviceAutomations(hass.callWS, id).catch(() => [] as T[])
|
||||
)
|
||||
);
|
||||
return candidates.filter((_id, index) =>
|
||||
findEquivalentDeviceAutomation(
|
||||
entityRegistry,
|
||||
automationsPerCandidate[index],
|
||||
automation
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const compareEntityIdWithEntityRegId = (
|
||||
|
||||
+35
-28
@@ -1909,20 +1909,13 @@ export const downloadEnergyData = (
|
||||
const device_consumption_water = energyData.prefs.device_consumption_water;
|
||||
const stats = energyData.state.stats;
|
||||
|
||||
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;
|
||||
interface CsvRow {
|
||||
id: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
data: StatisticValue[];
|
||||
}
|
||||
const rows: CsvRow[] = [];
|
||||
|
||||
const processCsvRow = function (
|
||||
id: string,
|
||||
@@ -1930,20 +1923,7 @@ export const downloadEnergyData = (
|
||||
unit: string,
|
||||
data: StatisticValue[]
|
||||
) {
|
||||
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");
|
||||
rows.push({ id, type, unit, data });
|
||||
};
|
||||
|
||||
const processStat = function (stat: string, type: string, unit: string) {
|
||||
@@ -2190,6 +2170,33 @@ 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",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
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> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
/** 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;
|
||||
};
|
||||
@@ -40,8 +40,7 @@ export const searchPlaces = (
|
||||
limit ? `&limit=${limit}` : ""
|
||||
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
|
||||
hass.locale.language
|
||||
}&email=abuse@home-assistant.io`,
|
||||
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
|
||||
}&email=abuse@home-assistant.io`
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
@@ -59,8 +58,7 @@ export const reverseGeocode = (
|
||||
location[1]
|
||||
}&accept-language=${hass.locale.language}&zoom=${
|
||||
zoom ?? 18
|
||||
}&format=jsonv2&email=abuse@home-assistant.io`,
|
||||
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
|
||||
}&format=jsonv2&email=abuse@home-assistant.io`
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
|
||||
@@ -160,15 +160,6 @@ 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[]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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,
|
||||
})
|
||||
);
|
||||
+10
-4
@@ -14,6 +14,7 @@ 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 = "________";
|
||||
|
||||
@@ -54,13 +55,18 @@ export const extractFromTarget = async (
|
||||
target: HassServiceTarget,
|
||||
expandGroup = false,
|
||||
primaryEntitiesOnly = true
|
||||
) =>
|
||||
callWS<ExtractFromTargetResult>({
|
||||
type: "extract_from_target",
|
||||
) => {
|
||||
const request = {
|
||||
type: "extract_from_target" as const,
|
||||
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 = {
|
||||
|
||||
@@ -526,6 +526,16 @@ 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
|
||||
|
||||
@@ -230,13 +230,13 @@ export class DialogForm
|
||||
): Promise<void> {
|
||||
await this._afterFormRender();
|
||||
|
||||
if (!this._open || this._params !== expectedParams) {
|
||||
if (!this.isConnected || !this._open || this._params !== expectedParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._waitForSelectorElements();
|
||||
|
||||
if (!this._open || this._params !== expectedParams) {
|
||||
if (!this.isConnected || !this._open || this._params !== expectedParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,7 +250,12 @@ export class DialogForm
|
||||
): Promise<void> {
|
||||
await this._afterFormRender();
|
||||
|
||||
if (!this._open || this._params !== expectedParams || !this._dialog) {
|
||||
if (
|
||||
!this.isConnected ||
|
||||
!this._open ||
|
||||
this._params !== expectedParams ||
|
||||
!this._dialog
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { mdiCheck, mdiContentCopy, mdiDevices, mdiTextureBox } from "@mdi/js";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../../common/entity/compute_floor_name";
|
||||
@@ -11,8 +13,16 @@ import { getEntityContext } from "../../common/entity/context/get_entity_context
|
||||
import checkValidDate from "../../common/datetime/check_valid_date";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import "../../components/ha-attribute-value";
|
||||
import "../../components/ha-floor-icon";
|
||||
import "../../components/ha-icon";
|
||||
import "../../components/ha-icon-next";
|
||||
import "../../components/ha-label";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/item/ha-list-item-button";
|
||||
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
|
||||
import "../../components/item/ha-list-item-value";
|
||||
import "../../components/list/ha-grouped-list";
|
||||
import { copyToClipboard } from "../../common/util/copy-clipboard";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
@@ -25,6 +35,8 @@ import { getFeatures } from "../../common/entity/get_domain_features";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import { titleCase } from "../../common/string/title-case";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import { showToast } from "../../util/toast";
|
||||
|
||||
interface DetailsViewParams {
|
||||
entityId: string;
|
||||
@@ -33,7 +45,10 @@ interface DetailsViewParams {
|
||||
interface DetailEntry {
|
||||
translationKey: LocalizeKeys;
|
||||
value: string;
|
||||
displayValue?: TemplateResult;
|
||||
href?: string;
|
||||
icon?: TemplateResult;
|
||||
copyable?: boolean;
|
||||
}
|
||||
|
||||
@customElement("ha-more-info-details")
|
||||
@@ -52,6 +67,17 @@ class HaMoreInfoDetails extends LitElement {
|
||||
@state()
|
||||
private _labels?: LabelRegistryEntry[];
|
||||
|
||||
@state() private _copiedValue?: string;
|
||||
|
||||
private _copyFeedbackTimeout?: number;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.clearTimeout(this._copyFeedbackTimeout);
|
||||
this._copyFeedbackTimeout = undefined;
|
||||
this._copiedValue = undefined;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("entry") && this.entry) {
|
||||
@@ -93,12 +119,12 @@ class HaMoreInfoDetails extends LitElement {
|
||||
? this.hass.localize(`component.${this.entry.platform}.title`) ||
|
||||
this.entry.platform
|
||||
: undefined;
|
||||
const labelNames =
|
||||
this.entry?.labels.map(
|
||||
(labelId) =>
|
||||
this._labels?.find((label) => label.label_id === labelId)?.name ??
|
||||
labelId
|
||||
) ?? [];
|
||||
const labels =
|
||||
this.entry?.labels.map((labelId) => ({
|
||||
id: labelId,
|
||||
entry: this._labels?.find((label) => label.label_id === labelId),
|
||||
})) ?? [];
|
||||
const labelNames = labels.map(({ id, entry }) => entry?.name ?? id);
|
||||
const contextEntries: DetailEntry[] = [];
|
||||
|
||||
if (floor && floorName) {
|
||||
@@ -106,6 +132,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
translationKey: "ui.dialogs.more_info_control.floor",
|
||||
value: floorName,
|
||||
href: "/config/areas/dashboard",
|
||||
icon: html`<ha-floor-icon slot="end" .floor=${floor}></ha-floor-icon>`,
|
||||
});
|
||||
}
|
||||
if (area && areaName) {
|
||||
@@ -113,6 +140,9 @@ class HaMoreInfoDetails extends LitElement {
|
||||
translationKey: "ui.components.related-items.area",
|
||||
value: areaName,
|
||||
href: `/config/areas/area/${area.area_id}`,
|
||||
icon: area.icon
|
||||
? html`<ha-icon slot="end" .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
|
||||
});
|
||||
}
|
||||
if (device && deviceName) {
|
||||
@@ -120,6 +150,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
translationKey: "ui.components.related-items.device",
|
||||
value: deviceName,
|
||||
href: `/config/devices/device/${device.id}`,
|
||||
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
|
||||
});
|
||||
}
|
||||
if (this.entry?.platform && integrationName) {
|
||||
@@ -129,31 +160,63 @@ class HaMoreInfoDetails extends LitElement {
|
||||
href: this.entry.config_entry_id
|
||||
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
|
||||
: undefined,
|
||||
icon: html`<img
|
||||
slot="end"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: this.entry.platform,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`,
|
||||
});
|
||||
}
|
||||
|
||||
const entityEntries: DetailEntry[] = [
|
||||
contextEntries.push(
|
||||
{
|
||||
translationKey: "ui.dialogs.more_info_control.entity_id",
|
||||
value: this.params.entityId,
|
||||
copyable: true,
|
||||
},
|
||||
{
|
||||
translationKey: "ui.dialogs.more_info_control.labels",
|
||||
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
|
||||
},
|
||||
];
|
||||
displayValue: labels.length
|
||||
? html`<div class="labels">
|
||||
${labels.map(
|
||||
({ id, entry }) => html`
|
||||
<ha-label
|
||||
class="text-ellipsis"
|
||||
.color=${entry?.color ?? undefined}
|
||||
.description=${entry?.description ?? undefined}
|
||||
>
|
||||
${
|
||||
entry?.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${entry.icon}
|
||||
></ha-icon>`
|
||||
: nothing
|
||||
}
|
||||
${entry?.name ?? id}
|
||||
</ha-label>
|
||||
`
|
||||
)}
|
||||
</div>`
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
const yamlData = {
|
||||
...(contextEntries.length
|
||||
? {
|
||||
context: {
|
||||
...(floorName ? { floor: floorName } : {}),
|
||||
...(areaName ? { area: areaName } : {}),
|
||||
...(deviceName ? { device: deviceName } : {}),
|
||||
...(integrationName ? { integration: integrationName } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
entity: {
|
||||
context: {
|
||||
...(floorName ? { floor: floorName } : {}),
|
||||
...(areaName ? { area: areaName } : {}),
|
||||
...(deviceName ? { device: deviceName } : {}),
|
||||
...(integrationName ? { integration: integrationName } : {}),
|
||||
entity_id: this.params.entityId,
|
||||
labels: labelNames,
|
||||
},
|
||||
@@ -171,17 +234,9 @@ class HaMoreInfoDetails extends LitElement {
|
||||
in-dialog
|
||||
></ha-yaml-editor>`
|
||||
: html`
|
||||
${
|
||||
contextEntries.length
|
||||
? html`<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.context"
|
||||
)}
|
||||
>
|
||||
${this._renderEntries(contextEntries)}
|
||||
</ha-grouped-list>`
|
||||
: nothing
|
||||
}
|
||||
<ha-grouped-list>
|
||||
${this._renderEntries(contextEntries)}
|
||||
</ha-grouped-list>
|
||||
|
||||
<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
@@ -191,14 +246,6 @@ class HaMoreInfoDetails extends LitElement {
|
||||
${this._renderEntries(stateEntries)}
|
||||
</ha-grouped-list>
|
||||
|
||||
<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.entity"
|
||||
)}
|
||||
>
|
||||
${this._renderEntries(entityEntries)}
|
||||
</ha-grouped-list>
|
||||
|
||||
<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.attributes"
|
||||
@@ -284,17 +331,72 @@ class HaMoreInfoDetails extends LitElement {
|
||||
}
|
||||
|
||||
private _renderEntries(entries: DetailEntry[]) {
|
||||
return entries.map(
|
||||
(entry) => html`
|
||||
<ha-list-item-value .label=${this.hass.localize(entry.translationKey)}>
|
||||
${
|
||||
entry.href
|
||||
? html`<a href=${entry.href}>${entry.value}</a>`
|
||||
: entry.value
|
||||
}
|
||||
</ha-list-item-value>
|
||||
`
|
||||
);
|
||||
return entries.map((entry) => {
|
||||
const label = this.hass.localize(entry.translationKey);
|
||||
|
||||
if (!entry.href && !entry.copyable) {
|
||||
return html`
|
||||
<ha-list-item-value .label=${label}
|
||||
>${entry.displayValue ?? entry.value}</ha-list-item-value
|
||||
>
|
||||
`;
|
||||
}
|
||||
|
||||
if (entry.copyable) {
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.copy_value",
|
||||
{ label, value: entry.value }
|
||||
)}
|
||||
data-value=${entry.value}
|
||||
@click=${this._copyValue}
|
||||
>
|
||||
<div class="link-row" slot="content">
|
||||
<div class="label">${label}</div>
|
||||
<div class="value">${entry.value}</div>
|
||||
</div>
|
||||
<ha-svg-icon
|
||||
class=${this._copiedValue === entry.value ? "copy-success" : ""}
|
||||
slot="end"
|
||||
.path=${
|
||||
this._copiedValue === entry.value ? mdiCheck : mdiContentCopy
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-list-item-button .href=${entry.href}>
|
||||
<div class="link-row" slot="content">
|
||||
<div class="label">${label}</div>
|
||||
<div class="value">${entry.value}</div>
|
||||
</div>
|
||||
${entry.icon ?? nothing}
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
private async _copyValue(ev: HASSDomCurrentTargetEvent<HaListItemButton>) {
|
||||
const value = ev.currentTarget.dataset.value;
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
await copyToClipboard(value);
|
||||
const duration = 4000;
|
||||
this._copiedValue = value;
|
||||
window.clearTimeout(this._copyFeedbackTimeout);
|
||||
this._copyFeedbackTimeout = window.setTimeout(() => {
|
||||
this._copiedValue = undefined;
|
||||
this._copyFeedbackTimeout = undefined;
|
||||
}, duration);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||
duration,
|
||||
});
|
||||
}
|
||||
|
||||
private _renderAttributes(attributes: { name: string; label: string }[]) {
|
||||
@@ -358,11 +460,63 @@ class HaMoreInfoDetails extends LitElement {
|
||||
}
|
||||
|
||||
ha-grouped-list + ha-grouped-list {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
ha-list-item-button {
|
||||
--ha-row-item-padding-block: var(--ha-space-2);
|
||||
--ha-row-item-min-height: 40px;
|
||||
--ha-row-item-gap: var(--ha-space-3);
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
ha-list-item-button::part(end) {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-list-item-button img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.link-row .label {
|
||||
flex: 1;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.link-row .value {
|
||||
max-width: 60%;
|
||||
min-width: 0;
|
||||
text-align: end;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.labels {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.labels ha-label {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
ha-icon-next {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-svg-icon.copy-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { mainWindow } from "../../common/dom/get_main_window";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
@@ -47,7 +48,10 @@ import {
|
||||
replaceCurrentUrl,
|
||||
updateHistoryState,
|
||||
} from "../../common/navigate";
|
||||
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
|
||||
import {
|
||||
createMoreInfoUrl,
|
||||
decodeMoreInfoUrl,
|
||||
} from "../../common/url/more-info-query-params";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { withViewTransition } from "../../common/util/view-transition";
|
||||
@@ -231,7 +235,13 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
if (this._returnUrl) {
|
||||
// Restore the pre-dialog URL only while the URL still carries this
|
||||
// dialog's deep-link params: navigate() waits for the close only up to
|
||||
// DIALOG_WAIT_TIMEOUT and may have committed a new URL already.
|
||||
if (
|
||||
this._returnUrl &&
|
||||
decodeMoreInfoUrl(mainWindow.location.search).entityId === this._entityId
|
||||
) {
|
||||
replaceCurrentUrl(this._returnUrl);
|
||||
}
|
||||
this._entityId = undefined;
|
||||
|
||||
@@ -117,6 +117,33 @@ const initRouting = () => {
|
||||
})
|
||||
);
|
||||
|
||||
// Strip the rotating token from the cache key, or every rotation refetches
|
||||
// every tile. The TileJSON is deliberately not matched: it is the switching
|
||||
// point for the tile source, so it stays on the network.
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
/^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.test(url.pathname),
|
||||
new CacheFirst({
|
||||
cacheName: "map-tiles",
|
||||
plugins: [
|
||||
{
|
||||
cacheKeyWillBeUsed: async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("token");
|
||||
return url.href;
|
||||
},
|
||||
},
|
||||
// A stale token gives a 403; caching that would pin the failure.
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7,
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Short-circuit camera/image proxy requests with an expired signature or a
|
||||
// missing/undefined token so they don't hit core and get logged as invalid
|
||||
// login attempts. Registered before the generic /api route below so it wins.
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface RouteOptions {
|
||||
// Function to load the page.
|
||||
load?: () => Promise<unknown>;
|
||||
cache?: boolean;
|
||||
// Recreate the page when the remaining path (the item id) changes.
|
||||
itemId?: boolean;
|
||||
waitForReady?: boolean;
|
||||
}
|
||||
|
||||
@@ -138,10 +140,23 @@ export class HassRouterPage extends ReactiveElement {
|
||||
}
|
||||
|
||||
if (this._currentPage === newPage) {
|
||||
if (this.lastChild) {
|
||||
this.updatePageEl(this.lastChild, changedProps);
|
||||
const oldRoute = changedProps.get("route");
|
||||
const oldTail = oldRoute ? computeRouteTail(oldRoute).path : undefined;
|
||||
const newTail = route ? this._computeTail(route).path : undefined;
|
||||
if (
|
||||
typeof routeOptions === "object" &&
|
||||
routeOptions.itemId &&
|
||||
oldTail !== newTail
|
||||
) {
|
||||
// Fall through to the normal create path so `load` / loading screen
|
||||
// still run. itemId pages are not cached, so this is a new element.
|
||||
this._currentPage = "";
|
||||
} else {
|
||||
if (this.lastChild) {
|
||||
this.updatePageEl(this.lastChild, changedProps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!routeOptions) {
|
||||
@@ -365,7 +380,10 @@ export class HassRouterPage extends ReactiveElement {
|
||||
this.updatePageEl(panelEl);
|
||||
this.appendChild(panelEl);
|
||||
|
||||
if (routerOptions.cacheAll || routeOptions.cache) {
|
||||
if (
|
||||
(routerOptions.cacheAll || routeOptions.cache) &&
|
||||
!routeOptions.itemId
|
||||
) {
|
||||
this._cache[page] = panelEl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { LitElement, PropertyValues } from "lit";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import type { UnsavedChangesGuard } from "../common/navigate";
|
||||
import {
|
||||
registerUnsavedChangesGuard,
|
||||
unregisterUnsavedChangesGuard,
|
||||
} from "../common/navigate";
|
||||
import type { Constructor } from "../types";
|
||||
|
||||
export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
|
||||
@@ -9,45 +13,34 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
|
||||
/** Provided by `DirtyStateProviderMixin`. */
|
||||
declare isDirtyState: boolean;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const result = await this.promptDiscardChanges();
|
||||
if (result) {
|
||||
this._removeListeners();
|
||||
if (target) {
|
||||
const newEvent = new MouseEvent(e.type, e);
|
||||
target.dispatchEvent(newEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
|
||||
private _removeListeners() {
|
||||
window.removeEventListener("click", this._handleClick, true);
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
private _unsavedChangesGuard: UnsavedChangesGuard = {
|
||||
isDirty: () => this.isDirtyState,
|
||||
prompt: () => this.promptDiscardChanges(),
|
||||
};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
registerUnsavedChangesGuard(this._unsavedChangesGuard);
|
||||
}
|
||||
|
||||
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 {
|
||||
this._removeListeners();
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this._removeListeners();
|
||||
unregisterUnsavedChangesGuard(this._unsavedChangesGuard);
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges(): Promise<boolean> {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
ACTION_ROW_CONFIG_KEYS,
|
||||
pickRowConfig,
|
||||
} from "../../../../data/automation";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
|
||||
@@ -107,9 +111,8 @@ export default class HaAutomationActionEditor extends LitElement {
|
||||
private _onUiChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = {
|
||||
...(this.action.alias ? { alias: this.action.alias } : {}),
|
||||
...(this.action.note ? { note: this.action.note } : {}),
|
||||
...ev.detail.value,
|
||||
...pickRowConfig(this.action, ACTION_ROW_CONFIG_KEYS),
|
||||
};
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationEditorMode,
|
||||
fetchReplacementDevices,
|
||||
fetchDeviceActions,
|
||||
deviceAutomationsEqual,
|
||||
fetchDeviceActionCapabilities,
|
||||
localizeExtraFieldsComputeHelperCallback,
|
||||
@@ -40,6 +42,8 @@ export class HaDeviceAction extends LitElement {
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _replacementDeviceIds?: string[];
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@@ -95,13 +99,27 @@ export class HaDeviceAction extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
|
||||
this._replacementDeviceIds = await fetchReplacementDevices(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
this.action,
|
||||
compositeSplits,
|
||||
fetchDeviceActions
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (this._loadingCompositeSplits) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
// Resolve the candidates before exposing the split map, so the picker
|
||||
// never offers one that cannot host the automation.
|
||||
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
await this._resolveReplacements(compositeSplits);
|
||||
this._compositeSplits = compositeSplits;
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
@@ -115,6 +133,7 @@ export class HaDeviceAction extends LitElement {
|
||||
return html`
|
||||
<ha-device-picker
|
||||
.value=${deviceId}
|
||||
.replacementDeviceIds=${this._replacementDeviceIds}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._devicePicked}
|
||||
.hass=${this.hass}
|
||||
@@ -156,6 +175,19 @@ export class HaDeviceAction extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
// The picked device only lives here until the configuration catches up.
|
||||
// Once it points somewhere else, undo and redo included, it is stale.
|
||||
const previous = changedProps.get("action");
|
||||
if (previous && previous.device_id !== this.action.device_id) {
|
||||
this._deviceId = undefined;
|
||||
this._replacementDeviceIds = undefined;
|
||||
if (this._compositeSplits) {
|
||||
this._resolveReplacements(this._compositeSplits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.hass.loadBackendTranslation("device_automation");
|
||||
if (!this._capabilities) {
|
||||
@@ -185,6 +217,15 @@ export class HaDeviceAction extends LitElement {
|
||||
|
||||
private _devicePicked(ev) {
|
||||
ev.stopPropagation();
|
||||
// The automation exists as is on the replacement, so only the reference
|
||||
// changes and the rest of the configuration is left untouched.
|
||||
if (this._replacementDeviceIds?.includes(ev.target.value)) {
|
||||
this._deviceId = undefined;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.action, device_id: ev.target.value },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._deviceId = ev.target.value;
|
||||
if (this._deviceId === undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -360,6 +361,7 @@ 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
|
||||
>`
|
||||
}`
|
||||
@@ -1444,6 +1446,26 @@ 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,7 +7,11 @@ import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { Condition } from "../../../../data/automation";
|
||||
import { expandConditionWithShorthand } from "../../../../data/automation";
|
||||
import {
|
||||
CONDITION_ROW_CONFIG_KEYS,
|
||||
expandConditionWithShorthand,
|
||||
pickRowConfig,
|
||||
} from "../../../../data/automation";
|
||||
import type { ConditionDescription } from "../../../../data/condition";
|
||||
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -125,9 +129,8 @@ export default class HaAutomationConditionEditor extends LitElement {
|
||||
private _onUiChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = {
|
||||
...(this.condition.alias ? { alias: this.condition.alias } : {}),
|
||||
...(this.condition.note ? { note: this.condition.note } : {}),
|
||||
...ev.detail.value,
|
||||
...pickRowConfig(this.condition, CONDITION_ROW_CONFIG_KEYS),
|
||||
};
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationEditorMode,
|
||||
fetchReplacementDevices,
|
||||
fetchDeviceConditions,
|
||||
deviceAutomationsEqual,
|
||||
fetchDeviceConditionCapabilities,
|
||||
localizeExtraFieldsComputeHelperCallback,
|
||||
@@ -40,6 +42,8 @@ export class HaDeviceCondition extends LitElement {
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _replacementDeviceIds?: string[];
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@@ -96,13 +100,27 @@ export class HaDeviceCondition extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
|
||||
this._replacementDeviceIds = await fetchReplacementDevices(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
this.condition,
|
||||
compositeSplits,
|
||||
fetchDeviceConditions
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (this._loadingCompositeSplits) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
// Resolve the candidates before exposing the split map, so the picker
|
||||
// never offers one that cannot host the automation.
|
||||
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
await this._resolveReplacements(compositeSplits);
|
||||
this._compositeSplits = compositeSplits;
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
@@ -116,6 +134,7 @@ export class HaDeviceCondition extends LitElement {
|
||||
return html`
|
||||
<ha-device-picker
|
||||
.value=${deviceId}
|
||||
.replacementDeviceIds=${this._replacementDeviceIds}
|
||||
@value-changed=${this._devicePicked}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
@@ -157,6 +176,19 @@ export class HaDeviceCondition extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
// The picked device only lives here until the configuration catches up.
|
||||
// Once it points somewhere else, undo and redo included, it is stale.
|
||||
const previous = changedProps.get("condition");
|
||||
if (previous && previous.device_id !== this.condition.device_id) {
|
||||
this._deviceId = undefined;
|
||||
this._replacementDeviceIds = undefined;
|
||||
if (this._compositeSplits) {
|
||||
this._resolveReplacements(this._compositeSplits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.hass.loadBackendTranslation("device_automation");
|
||||
if (!this._capabilities) {
|
||||
@@ -187,6 +219,15 @@ export class HaDeviceCondition extends LitElement {
|
||||
|
||||
private _devicePicked(ev) {
|
||||
ev.stopPropagation();
|
||||
// The automation exists as is on the replacement, so only the reference
|
||||
// changes and the rest of the configuration is left untouched.
|
||||
if (this._replacementDeviceIds?.includes(ev.target.value)) {
|
||||
this._deviceId = undefined;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.condition, device_id: ev.target.value },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._deviceId = ev.target.value;
|
||||
if (this._deviceId === undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
|
||||
@@ -896,10 +896,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
return;
|
||||
}
|
||||
|
||||
this.yamlErrors = undefined;
|
||||
resolve(true);
|
||||
},
|
||||
onClose: () => resolve(false),
|
||||
onDiscard: () => resolve(true),
|
||||
onDiscard: () => {
|
||||
this.yamlErrors = undefined;
|
||||
this._markDirtyStateClean();
|
||||
resolve(true);
|
||||
},
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
entityRegistryEntry: this.registryEntry,
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -278,6 +278,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
const domain = hooks.domain;
|
||||
try {
|
||||
const config = await hooks.fetchFileConfig(this.hass, id);
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.readOnly = false;
|
||||
const report: AutomationMigrationReport = { deprecated: false };
|
||||
this.config = hooks.normalizeConfig(config, report);
|
||||
@@ -294,6 +297,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
);
|
||||
hooks.checkValidation();
|
||||
} catch (err: any) {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (err.status_code !== 404) {
|
||||
const alertText =
|
||||
err.body?.message || err.body || err.error || "Unknown error";
|
||||
|
||||
@@ -47,9 +47,11 @@ class HaConfigAutomation extends HassRouterPage {
|
||||
},
|
||||
edit: {
|
||||
tag: "ha-automation-editor",
|
||||
itemId: true,
|
||||
},
|
||||
show: {
|
||||
tag: "ha-automation-editor",
|
||||
itemId: true,
|
||||
},
|
||||
trace: {
|
||||
tag: "ha-automation-trace",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mdiFormatListBulleted,
|
||||
mdiMenuDown,
|
||||
mdiShape,
|
||||
mdiSwapHorizontal,
|
||||
} from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import {
|
||||
@@ -321,14 +322,25 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
|
||||
let lastTargetType: string | null = null;
|
||||
|
||||
// The collapsed summary hides the individual targets, so carry over the
|
||||
// warning when any of them no longer exists.
|
||||
const hasMissingTarget = rows.some(
|
||||
([targetType, targetId]) => !this._checkTargetExists(targetType, targetId)
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dropdown
|
||||
@wa-select=${this._handleTargetSelect}
|
||||
@click=${stopPropagation}
|
||||
@keydown=${stopPropagation}
|
||||
>
|
||||
<button slot="trigger" class="target">
|
||||
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
|
||||
<button
|
||||
slot="trigger"
|
||||
class=${classMap({ target: true, warning: hasMissingTarget })}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${hasMissingTarget ? mdiAlert : mdiFormatListBulleted}
|
||||
></ha-svg-icon>
|
||||
<div class="label">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.target_summary.targets",
|
||||
@@ -467,9 +479,15 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
warning = true;
|
||||
badgeTargetId = undefined;
|
||||
badgeTargetType = undefined;
|
||||
if (targetType === "device" && this._compositeSplits?.[targetId]) {
|
||||
if (
|
||||
targetType === "device" &&
|
||||
this._compositeSplits?.[targetId]?.split_ids.some(
|
||||
(id) => id in this._registries.devices
|
||||
)
|
||||
) {
|
||||
// The device was replaced by one or more split devices; make clear
|
||||
// this reference needs to be updated, distinct from "unknown device".
|
||||
icon = mdiSwapHorizontal;
|
||||
label = this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.target_summary.device_replaced"
|
||||
);
|
||||
@@ -686,6 +704,9 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
background-color: var(--ha-color-fill-warning-quiet-resting);
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
}
|
||||
ha-dropdown-item.warning ha-svg-icon {
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
}
|
||||
ha-dropdown-item.warning:hover {
|
||||
background-color: var(--ha-color-fill-warning-quiet-hover);
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
|
||||
@@ -8,7 +8,11 @@ import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { Trigger } from "../../../../data/automation";
|
||||
import { migrateAutomationTrigger } from "../../../../data/automation";
|
||||
import {
|
||||
TRIGGER_ROW_CONFIG_KEYS,
|
||||
migrateAutomationTrigger,
|
||||
pickRowConfig,
|
||||
} from "../../../../data/automation";
|
||||
import type { TriggerDescription } from "../../../../data/trigger";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
@@ -144,9 +148,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
if (isTriggerList(this.trigger)) return;
|
||||
ev.stopPropagation();
|
||||
const value = {
|
||||
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
|
||||
...(this.trigger.note ? { note: this.trigger.note } : {}),
|
||||
...ev.detail.value,
|
||||
...pickRowConfig(this.trigger, TRIGGER_ROW_CONFIG_KEYS),
|
||||
};
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationEditorMode,
|
||||
fetchReplacementDevices,
|
||||
fetchDeviceTriggers,
|
||||
deviceAutomationsEqual,
|
||||
fetchDeviceTriggerCapabilities,
|
||||
localizeExtraFieldsComputeHelperCallback,
|
||||
@@ -42,6 +44,8 @@ export class HaDeviceTrigger extends LitElement {
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _replacementDeviceIds?: string[];
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@@ -100,13 +104,27 @@ export class HaDeviceTrigger extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
|
||||
this._replacementDeviceIds = await fetchReplacementDevices(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
this.trigger,
|
||||
compositeSplits,
|
||||
fetchDeviceTriggers
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (this._loadingCompositeSplits) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
// Resolve the candidates before exposing the split map, so the picker
|
||||
// never offers one that cannot host the automation.
|
||||
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
await this._resolveReplacements(compositeSplits);
|
||||
this._compositeSplits = compositeSplits;
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
@@ -120,6 +138,7 @@ export class HaDeviceTrigger extends LitElement {
|
||||
return html`
|
||||
<ha-device-picker
|
||||
.value=${deviceId}
|
||||
.replacementDeviceIds=${this._replacementDeviceIds}
|
||||
@value-changed=${this._devicePicked}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
@@ -161,6 +180,19 @@ export class HaDeviceTrigger extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
// The picked device only lives here until the configuration catches up.
|
||||
// Once it points somewhere else, undo and redo included, it is stale.
|
||||
const previous = changedProps.get("trigger");
|
||||
if (previous && previous.device_id !== this.trigger.device_id) {
|
||||
this._deviceId = undefined;
|
||||
this._replacementDeviceIds = undefined;
|
||||
if (this._compositeSplits) {
|
||||
this._resolveReplacements(this._compositeSplits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.hass.loadBackendTranslation("device_automation");
|
||||
if (!this._capabilities) {
|
||||
@@ -208,6 +240,15 @@ export class HaDeviceTrigger extends LitElement {
|
||||
|
||||
private _devicePicked(ev) {
|
||||
ev.stopPropagation();
|
||||
// The automation exists as is on the replacement, so only the reference
|
||||
// changes and the rest of the configuration is left untouched.
|
||||
if (this._replacementDeviceIds?.includes(ev.target.value)) {
|
||||
this._deviceId = undefined;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.trigger, device_id: ev.target.value },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._deviceId = ev.target.value;
|
||||
if (this._deviceId === undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
@@ -225,9 +266,6 @@ export class HaDeviceTrigger extends LitElement {
|
||||
) {
|
||||
trigger = this._origTrigger;
|
||||
}
|
||||
if (this.trigger.id) {
|
||||
trigger.id = this.trigger.id;
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: trigger });
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import type { PlatformTrigger } from "../../../../../data/automation";
|
||||
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
@@ -27,15 +28,11 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
|
||||
!("boolean" in field.selector && field.default);
|
||||
|
||||
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
|
||||
...TRIGGER_ROW_CONFIG_KEYS,
|
||||
"trigger",
|
||||
"target",
|
||||
"alias",
|
||||
"note",
|
||||
"id",
|
||||
"variables",
|
||||
"enabled",
|
||||
"options",
|
||||
] as const;
|
||||
];
|
||||
|
||||
@customElement("ha-automation-trigger-platform")
|
||||
export class HaPlatformTrigger extends LitElement {
|
||||
|
||||
@@ -57,7 +57,7 @@ export class CloudAccountOverview extends LitElement {
|
||||
|
||||
private _renderTopCard(): TemplateResult {
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<ha-card outlined class="summary-card">
|
||||
<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,6 +132,7 @@ 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"
|
||||
@@ -156,6 +157,7 @@ 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"
|
||||
@@ -181,6 +183,7 @@ 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"
|
||||
@@ -636,11 +639,15 @@ export class CloudAccountOverview extends LitElement {
|
||||
width: auto;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-3);
|
||||
/* Prevent the embedded .subscription-alert ha-alert from bleeding outside the card */
|
||||
.summary-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-alert ha-button[slot="action"] {
|
||||
.subscription-alert {
|
||||
display: block;
|
||||
--ha-alert-padding: var(--ha-space-3);
|
||||
}
|
||||
.subscription-alert ha-button[slot="action"] {
|
||||
width: max-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -256,10 +256,14 @@ class MqttSubscribeCard extends LitElement {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
ha-button {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
ha-select {
|
||||
width: 96px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
ha-input {
|
||||
flex: 1;
|
||||
@@ -281,10 +285,6 @@ class MqttSubscribeCard extends LitElement {
|
||||
@media screen and (max-width: 600px) {
|
||||
ha-select {
|
||||
display: block;
|
||||
margin-left: 0px;
|
||||
margin-top: 8px;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
ha-input {
|
||||
flex: auto;
|
||||
|
||||
+23
-51
@@ -1,11 +1,13 @@
|
||||
import { mdiContentCopy } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-dialog";
|
||||
import "../../../../../components/ha-dialog-footer";
|
||||
import "../../../../../components/ha-adaptive-dialog";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/item/ha-list-item-value";
|
||||
import "../../../../../components/list/ha-grouped-list";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { showToast } from "../../../../../util/toast";
|
||||
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
|
||||
@@ -85,64 +87,34 @@ class DialogSerialPortInfo extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
<ha-adaptive-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.serial.port_information"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
${this._fields().map(
|
||||
([label, value]) => html`
|
||||
<tr>
|
||||
<th>${label}</th>
|
||||
<td>${value}</td>
|
||||
</tr>
|
||||
`
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this._copyToClipboard}
|
||||
>
|
||||
${this.hass.localize("ui.common.copy")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this.closeDialog}>
|
||||
${this.hass.localize("ui.common.close")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
<ha-icon-button
|
||||
slot="headerActionItems"
|
||||
.label=${this.hass.localize("ui.common.copy")}
|
||||
.path=${mdiContentCopy}
|
||||
@click=${this._copyToClipboard}
|
||||
></ha-icon-button>
|
||||
<ha-grouped-list>
|
||||
${this._fields().map(
|
||||
([label, value]) =>
|
||||
html`<ha-list-item-value .label=${label}
|
||||
>${value}</ha-list-item-value
|
||||
>`
|
||||
)}
|
||||
</ha-grouped-list>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
|
||||
td {
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
tr:not(:first-child) th,
|
||||
tr:not(:first-child) td {
|
||||
padding-top: var(--ha-space-2);
|
||||
ha-grouped-list {
|
||||
--ha-list-item-value-max-width: 80%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
+24
-9
@@ -192,6 +192,16 @@ export class SerialConfigDashboard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _renderConsumerIcon(src: string, alt: string): TemplateResult {
|
||||
return html`<img
|
||||
slot="start"
|
||||
.src=${src}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${alt}
|
||||
/>`;
|
||||
}
|
||||
|
||||
private _renderConsumer(consumer: SerialPortConsumer): TemplateResult {
|
||||
const href =
|
||||
consumer.kind === "config_entry"
|
||||
@@ -202,21 +212,26 @@ export class SerialConfigDashboard extends LitElement {
|
||||
<ha-md-list-item type="link" href=${href} class="consumer">
|
||||
${
|
||||
consumer.kind === "config_entry"
|
||||
? html`<img
|
||||
slot="start"
|
||||
.src=${brandsUrl(
|
||||
? this._renderConsumerIcon(
|
||||
brandsUrl(
|
||||
{
|
||||
domain: consumer.domain!,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${consumer.domain!}
|
||||
/>`
|
||||
: html`<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>`
|
||||
),
|
||||
consumer.domain!
|
||||
)
|
||||
: consumer.kind === "app"
|
||||
? this._renderConsumerIcon(
|
||||
`/api/hassio/addons/${consumer.slug}/icon`,
|
||||
consumer.slug!
|
||||
)
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPuzzle}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div slot="headline">${this._consumerName(consumer)}</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
|
||||
+112
-3
@@ -1,3 +1,4 @@
|
||||
import { mdiSpiderWeb } from "@mdi/js";
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
@@ -16,6 +17,7 @@ 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";
|
||||
@@ -25,6 +27,7 @@ import type {
|
||||
ZWaveJSNodeStatus,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import {
|
||||
fetchZwaveNetworkNeighbors,
|
||||
fetchZwaveNetworkStatus,
|
||||
getNodeIdFromDevice,
|
||||
NodeStatus,
|
||||
@@ -33,6 +36,7 @@ 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) {
|
||||
@@ -55,11 +59,19 @@ 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> = {};
|
||||
@@ -89,6 +101,32 @@ 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();
|
||||
@@ -116,13 +154,23 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
.searchFilter=${this._searchFilter}
|
||||
.data=${this._getNetworkData(
|
||||
this._nodeStatuses,
|
||||
this._nodeStatistics
|
||||
this._nodeStatistics,
|
||||
this._showNeighbors ? this._neighbors : undefined
|
||||
)}
|
||||
.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>
|
||||
`;
|
||||
@@ -184,6 +232,13 @@ 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];
|
||||
@@ -263,7 +318,8 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
private _getNetworkData = memoizeOne(
|
||||
(
|
||||
nodeStatuses: Record<number, ZWaveJSNodeStatus>,
|
||||
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>
|
||||
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>,
|
||||
neighbors: Record<number, number[]> | undefined
|
||||
): NetworkData => {
|
||||
const style = getComputedStyle(this);
|
||||
const nodes: NetworkNode[] = [];
|
||||
@@ -420,7 +476,49 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
// 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 };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -460,6 +558,17 @@ 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,6 +40,7 @@ class HaConfigScene extends HassRouterPage {
|
||||
},
|
||||
edit: {
|
||||
tag: "ha-scene-editor",
|
||||
itemId: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -911,11 +911,15 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
}
|
||||
|
||||
private async _subscribeEvents() {
|
||||
this._unsubscribeEvents =
|
||||
await this.hass!.connection.subscribeEvents<HassEvent>(
|
||||
(event) => this._stateChanged(event),
|
||||
"state_changed"
|
||||
);
|
||||
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
|
||||
(event) => this._stateChanged(event),
|
||||
"state_changed"
|
||||
);
|
||||
if (!this.isConnected || this._mode !== "live") {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
this._unsubscribeEvents = unsubscribe;
|
||||
}
|
||||
|
||||
private _showMoreInfo(ev: Event) {
|
||||
@@ -928,6 +932,9 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
try {
|
||||
config = await getSceneConfig(this.hass, this.sceneId!);
|
||||
} catch (err: any) {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
await showAlertDialog(this, {
|
||||
text:
|
||||
err.status_code === 404
|
||||
@@ -943,6 +950,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.entities) {
|
||||
config.entities = {};
|
||||
}
|
||||
@@ -1115,20 +1126,24 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
}
|
||||
|
||||
private async _confirmUnsavedChanged(): Promise<boolean> {
|
||||
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,
|
||||
});
|
||||
if (!this.isDirtyState) {
|
||||
return true;
|
||||
}
|
||||
return 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;
|
||||
}
|
||||
|
||||
private async _duplicate() {
|
||||
|
||||
@@ -40,9 +40,11 @@ class HaConfigScript extends HassRouterPage {
|
||||
},
|
||||
edit: {
|
||||
tag: "ha-script-editor",
|
||||
itemId: true,
|
||||
},
|
||||
show: {
|
||||
tag: "ha-script-editor",
|
||||
itemId: true,
|
||||
},
|
||||
trace: {
|
||||
tag: "ha-script-trace",
|
||||
|
||||
@@ -804,10 +804,15 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
return;
|
||||
}
|
||||
|
||||
this.yamlErrors = undefined;
|
||||
resolve(true);
|
||||
},
|
||||
onClose: () => resolve(false),
|
||||
onDiscard: () => resolve(true),
|
||||
onDiscard: () => {
|
||||
this.yamlErrors = undefined;
|
||||
this._markDirtyStateClean();
|
||||
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,9 +395,11 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
aliases: entry?.aliases || [],
|
||||
};
|
||||
}
|
||||
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
|
||||
result[entityId].assistants
|
||||
);
|
||||
if (result[entityId]) {
|
||||
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
|
||||
result[entityId].assistants
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return Object.values(result);
|
||||
|
||||
@@ -266,7 +266,6 @@ class HaPanelHistory extends LitElement {
|
||||
.startTime=${this._startDate}
|
||||
.endTime=${this._endDate}
|
||||
.narrow=${this.narrow}
|
||||
inside-labels
|
||||
sync-charts
|
||||
>
|
||||
</state-history-charts>
|
||||
@@ -810,12 +809,7 @@ class HaPanelHistory extends LitElement {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden auto;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
/* Line the charts up with the toolbar when there are no axis labels. */
|
||||
:host([narrow]) .results {
|
||||
padding-inline: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
|
||||
@@ -188,7 +188,7 @@ class HuiHistoryChartCardFeature
|
||||
}
|
||||
if (this._coordinates && !this._coordinates.length) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="container no-history-found">
|
||||
<div class="info">
|
||||
${this.hass!.localize(
|
||||
"ui.components.history_charts.no_history_found"
|
||||
@@ -280,6 +280,14 @@ 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,22 +217,34 @@ export class HuiClockCardDigital extends LitElement {
|
||||
"hour minute second"
|
||||
"hour minute am-pm";
|
||||
|
||||
font-size: 1.5rem;
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-small,
|
||||
var(--ha-font-size-2xl)
|
||||
);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: 0.8;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.time-title + .time-parts {
|
||||
font-size: 1.5rem;
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-small,
|
||||
var(--ha-font-size-2xl)
|
||||
);
|
||||
}
|
||||
|
||||
.time-parts.size-medium {
|
||||
font-size: 3rem;
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-medium,
|
||||
calc(48px * var(--ha-font-size-scale))
|
||||
);
|
||||
}
|
||||
|
||||
.time-parts.size-large {
|
||||
font-size: 4rem;
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-large,
|
||||
calc(64px * var(--ha-font-size-scale))
|
||||
);
|
||||
}
|
||||
|
||||
.time-parts.size-medium .time-part.second,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* When battery is charging from grid, per-source import attribution is
|
||||
* ambiguous if multiple grid sources have data in the same period.
|
||||
* Rewrites single-source periods in place on `fromGridBySource`, and returns
|
||||
* a combined used-grid map for multi-source periods. Returns undefined when
|
||||
* no combined series is needed so the chart does not add an empty legend item.
|
||||
*/
|
||||
export function buildCombinedUsedGrid(
|
||||
fromGridBySource: Record<string, Record<number, number>>,
|
||||
gridToBattery: Record<number, number>,
|
||||
usedGrid: Record<number, number>
|
||||
): Record<number, number> | undefined {
|
||||
const used_grid: Record<number, number> = {};
|
||||
for (const [start, grid_to_battery] of Object.entries(gridToBattery)) {
|
||||
if (!grid_to_battery) {
|
||||
continue;
|
||||
}
|
||||
let noOfSources = 0;
|
||||
let source: string | undefined;
|
||||
for (const [key, stats] of Object.entries(fromGridBySource)) {
|
||||
if (stats[start]) {
|
||||
source = key;
|
||||
noOfSources++;
|
||||
}
|
||||
if (noOfSources > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noOfSources === 1 && source) {
|
||||
fromGridBySource[source][start] = usedGrid[start];
|
||||
} else {
|
||||
Object.values(fromGridBySource).forEach((stats) => {
|
||||
delete stats[start];
|
||||
});
|
||||
used_grid[start] = usedGrid[start];
|
||||
}
|
||||
}
|
||||
return Object.keys(used_grid).length > 0 ? used_grid : undefined;
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./common/energy-chart-options";
|
||||
import type { HaECOption } from "../../../../resources/echarts/echarts";
|
||||
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
|
||||
import { buildCombinedUsedGrid } from "./energy-usage-graph-used-grid";
|
||||
|
||||
const colorPropertyMap = {
|
||||
to_grid: "--energy-grid-return-color",
|
||||
@@ -585,7 +586,8 @@ export class HuiEnergyUsageGraphCard
|
||||
|
||||
// Only add solar/battery consumption series when such a source is
|
||||
// actually configured, otherwise the legend shows empty solar/battery
|
||||
// entries for grid-only setups.
|
||||
// entries for grid-only setups. Combined used_grid is a fallback for
|
||||
// multi-source battery charging; skip it when it has no points.
|
||||
if (statIdsByCat.solar) {
|
||||
combinedData.used_solar = { used_solar: consumptionData.used_solar };
|
||||
}
|
||||
@@ -596,38 +598,14 @@ export class HuiEnergyUsageGraphCard
|
||||
}
|
||||
|
||||
if (combinedData.from_grid && summedData.to_battery) {
|
||||
const used_grid = {};
|
||||
// If we have to_battery and multiple grid sources in the same period, we
|
||||
// can't determine which source was used. So delete all the individual
|
||||
// sources and replace with a 'combined from grid' value.
|
||||
for (const [start, grid_to_battery] of Object.entries(
|
||||
consumptionData.grid_to_battery
|
||||
)) {
|
||||
if (!grid_to_battery) {
|
||||
continue;
|
||||
}
|
||||
let noOfSources = 0;
|
||||
let source: string;
|
||||
for (const [key, stats] of Object.entries(combinedData.from_grid)) {
|
||||
if (stats[start]) {
|
||||
source = key;
|
||||
noOfSources++;
|
||||
}
|
||||
if (noOfSources > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noOfSources === 1) {
|
||||
combinedData.from_grid[source!][start] =
|
||||
consumptionData.used_grid[start];
|
||||
} else {
|
||||
Object.values(combinedData.from_grid).forEach((stats) => {
|
||||
delete stats[start];
|
||||
});
|
||||
used_grid[start] = consumptionData.used_grid[start];
|
||||
}
|
||||
const used_grid = buildCombinedUsedGrid(
|
||||
combinedData.from_grid,
|
||||
consumptionData.grid_to_battery,
|
||||
consumptionData.used_grid
|
||||
);
|
||||
if (used_grid) {
|
||||
combinedData.used_grid = { used_grid };
|
||||
}
|
||||
combinedData.used_grid = { used_grid };
|
||||
}
|
||||
|
||||
const uniqueKeys = summedData.timestamps;
|
||||
|
||||
@@ -165,7 +165,7 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _currentBackgroundImage(): string | undefined {
|
||||
const background = this._backgroundData(this._config);
|
||||
const background = this._backgroundData(this.config);
|
||||
return typeof background.image === "object"
|
||||
? background.image.media_content_id
|
||||
: background.image;
|
||||
|
||||
+23
-17
@@ -802,8 +802,10 @@
|
||||
"device_not_found": "Device not found",
|
||||
"entity_not_found": "Entity not found",
|
||||
"label_not_found": "Label not found",
|
||||
"device_replaced_headline": "Replaced device",
|
||||
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
"replace_device": "Replace",
|
||||
"device_replaced_by_one": "Replaced by {device}",
|
||||
"replace_update": "Update",
|
||||
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
|
||||
"entities_count_filtered": "{count}/{total} {total, plural,\n one {entity}\n other {entities}\n}",
|
||||
@@ -924,10 +926,12 @@
|
||||
"no_area": "No area",
|
||||
"placeholder": "Select a device",
|
||||
"unknown": "Unknown device selected",
|
||||
"device_replaced_count": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
"device_replaced_by_one": "This device was replaced by {device}.",
|
||||
"device_replaced_by_multiple": "This device was replaced by {count} devices.",
|
||||
"replace_device": "Replace",
|
||||
"device_replaced": "Replaced device",
|
||||
"device_replaced_by_one": "This device no longer exists. It was replaced by {device}.",
|
||||
"device_replaced_by_one_integration": "This device no longer exists. It was replaced by {device} from {integration}.",
|
||||
"device_replaced_by_multiple": "This device no longer exists. It was replaced by {count} devices, pick the one to use here.",
|
||||
"replace_update": "Update",
|
||||
"replace_choose": "Choose",
|
||||
"replaced_dialog": {
|
||||
"title": "Replace device",
|
||||
"description": "This device was replaced by multiple devices. Choose which one to use.",
|
||||
@@ -1680,10 +1684,9 @@
|
||||
"person": "Edit person"
|
||||
},
|
||||
"details": "Details",
|
||||
"context": "Context",
|
||||
"entity": "Entity",
|
||||
"floor": "Floor",
|
||||
"entity_id": "Entity ID",
|
||||
"copy_value": "Copy {label}: {value}",
|
||||
"labels": "Labels",
|
||||
"toggle_yaml_mode": "Toggle YAML mode",
|
||||
"translated": "Translated",
|
||||
@@ -6441,17 +6444,17 @@
|
||||
"trial_note": "Try free for a month. No payment information required up front.",
|
||||
"partner_note": "This service is run by our partner {nabu_casa_link}, a company founded by the founders of Home Assistant.",
|
||||
"feature_remote_title": "Access from anywhere",
|
||||
"feature_remote_body": "Securely reach your Home Assistant from any device, with no port forwarding or VPN.",
|
||||
"feature_remote_body": "Securely and privately reach your Home Assistant when you're away from home. Zero router configuration, port forwarding, or complex VPNs.",
|
||||
"feature_backup_title": "Backups kept safe",
|
||||
"feature_backup_body": "Your latest Home Assistant backup, stored off-site and encrypted, ready to restore your full system on first boot.",
|
||||
"feature_voice_control_title": "Native Alexa and Google support",
|
||||
"feature_voice_control_body": "Control every Home Assistant device through Amazon Alexa and Google Assistant, so the devices and routines you already rely on keep working. Set up in minutes, not hours.",
|
||||
"feature_voice_quality_title": "Faster speech for your own assistant",
|
||||
"feature_voice_quality_body": "More languages and higher accuracy for Home Assistant's privacy-first voice assistant.",
|
||||
"feature_companion_title": "Companion app",
|
||||
"feature_companion_body": "Reach your home from your phone anywhere, with location and sensor updates flowing back securely and no ports to open.",
|
||||
"feature_backup_body": "Your latest Home Assistant backup, automatically stored somewhere safe and only accessible with your private key, ready to restore your full system with one click.",
|
||||
"feature_voice_control_title": "Alexa and Google Home made simple",
|
||||
"feature_voice_control_body": "Ease your transition to Home Assistant, use your Google and Alexa speakers to control every device. Set up in minutes, not hours.",
|
||||
"feature_voice_quality_title": "Fast, accurate voice control",
|
||||
"feature_voice_quality_body": "More languages and higher accuracy for your own private voice assistant powered by Home Assistant.",
|
||||
"feature_companion_title": "Secure app connectivity",
|
||||
"feature_companion_body": "The Home Assistant app on your mobile device forms a secure connection home, encrypting your location and sensor data.",
|
||||
"feature_support_title": "Support Home Assistant",
|
||||
"feature_support_body": "Your subscription funds the Open Home Foundation, keeping Home Assistant independent with no ads or data harvesting.",
|
||||
"feature_support_body": "Your subscription directly funds the Open Home Foundation, keeping Home Assistant independent and free from ads or data harvesting.",
|
||||
"sign_in": "Sign in",
|
||||
"sign_in_lead": "Sign in to your Nabu Casa account.",
|
||||
"email": "Email",
|
||||
@@ -8400,7 +8403,10 @@
|
||||
"status": "Status",
|
||||
"version": "Version",
|
||||
"data_rate": "Data rate",
|
||||
"area": "Area"
|
||||
"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"
|
||||
},
|
||||
"node_status": {
|
||||
"0": "Unknown",
|
||||
|
||||
Vendored
+8
@@ -13,3 +13,11 @@ 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";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LeafletModuleType } from "../../../src/common/dom/setup-leaflet-map";
|
||||
|
||||
// The fallback to raster tiles is what keeps the map working on devices
|
||||
// without WebGL2, on instances that cannot load the MapLibre chunk, and when
|
||||
// building the MapLibre map itself fails - a blocked worker, an exhausted
|
||||
// WebGL context budget. None of that is reachable from the `ha-map` tests,
|
||||
// which run in jsdom and therefore only ever take the raster branch.
|
||||
|
||||
const maplibreLayer = vi.hoisted(() => ({
|
||||
addTo: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
options: {} as { attribution?: string },
|
||||
getMaplibreMap: vi.fn(),
|
||||
}));
|
||||
|
||||
const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
|
||||
|
||||
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
|
||||
|
||||
const STYLE = {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }],
|
||||
};
|
||||
|
||||
const rasterLayer = { addTo: vi.fn(), 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, () => void> = {};
|
||||
const glMap = {
|
||||
setStyle: vi.fn(),
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
glHandlers[event] = handler;
|
||||
}),
|
||||
};
|
||||
const map = { on: vi.fn() } as any;
|
||||
|
||||
// The WebGL2 probe is cached for the lifetime of the module, so every test
|
||||
// needs its own copy of it.
|
||||
const setWebGL2 = async (supported: boolean) => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() =>
|
||||
supported ? ({ getExtension: () => null } as any) : null
|
||||
);
|
||||
return (await import("../../../src/common/map/base-layer")).createBaseLayer;
|
||||
};
|
||||
|
||||
const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
maplibreLayer.options = {};
|
||||
maplibreGL.mockReturnValue(maplibreLayer);
|
||||
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
|
||||
maplibreLayer.getMaplibreMap.mockReturnValue(glMap);
|
||||
for (const event of Object.keys(glHandlers)) {
|
||||
delete glHandlers[event];
|
||||
}
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("createBaseLayer", () => {
|
||||
it("falls back to raster tiles without WebGL2", async () => {
|
||||
const createBaseLayer = await setWebGL2(false);
|
||||
|
||||
await createBaseLayer(leaflet, map, false, 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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { NavigateOptions } from "../../src/common/navigate";
|
||||
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
|
||||
import type {
|
||||
NavigateOptions,
|
||||
UnsavedChangesGuard,
|
||||
} from "../../src/common/navigate";
|
||||
import {
|
||||
canGoBack,
|
||||
goBack,
|
||||
navigate,
|
||||
registerUnsavedChangesGuard,
|
||||
unregisterUnsavedChangesGuard,
|
||||
} from "../../src/common/navigate";
|
||||
|
||||
// navigate() closes open dialogs before touching history.
|
||||
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
|
||||
@@ -54,6 +63,145 @@ 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");
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SVGRenderer } from "echarts/renderers";
|
||||
import echarts from "../../../src/resources/echarts/echarts";
|
||||
import { createYAxisPrecisionBounds } from "../../../src/components/chart/y-axis-fraction-digits";
|
||||
|
||||
// jsdom has no canvas, and zrender reaches for one to measure label text.
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
measureText: () => ({ width: 10 }),
|
||||
})) as any;
|
||||
|
||||
// The app registers the canvas renderer; jsdom needs the SVG one.
|
||||
echarts.use([SVGRenderer]);
|
||||
|
||||
// The gap is opened by ECharts' own tick rounding, not by our helper, so these
|
||||
// assert the extent ECharts actually renders rather than what we hand it.
|
||||
const renderExtent = (
|
||||
data: number[],
|
||||
yAxis: Record<string, unknown>,
|
||||
nextYAxis?: Record<string, unknown>
|
||||
): [number, number] => {
|
||||
const chart = echarts.init(null, null, {
|
||||
ssr: true,
|
||||
renderer: "svg",
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
const option = (axis: Record<string, unknown>) => ({
|
||||
xAxis: { type: "category", data: data.map((_, index) => String(index)) },
|
||||
yAxis: { type: "value", ...axis },
|
||||
series: [{ type: "line", data }],
|
||||
});
|
||||
chart.setOption(option(yAxis));
|
||||
if (nextYAxis) {
|
||||
chart.setOption(option(nextYAxis), { replaceMerge: ["series"] });
|
||||
}
|
||||
// getModel() is internal, but it is the only way to read the rendered extent.
|
||||
const extent = (chart as any)
|
||||
.getModel()
|
||||
.getComponent("yAxis")
|
||||
.axis.scale.getExtent();
|
||||
chart.dispose();
|
||||
return extent;
|
||||
};
|
||||
|
||||
const withGap = (includeZero = false, unit?: string) => ({
|
||||
scale: !includeZero,
|
||||
...createYAxisPrecisionBounds({
|
||||
includeZero,
|
||||
unit,
|
||||
onFractionDigits: () => undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
describe("Y-axis tick nudge", () => {
|
||||
it("opens a gap under data that lands exactly on a tick", () => {
|
||||
// The reported bug: a thermostat's states are quantized, so the minimum is
|
||||
// an exact tick and the HVAC action band has nothing to fill.
|
||||
expect(renderExtent([18, 21.2, 19], { scale: true })).toEqual([18, 21.5]);
|
||||
expect(renderExtent([18, 21.2, 19], withGap())).toEqual([17.5, 21.5]);
|
||||
});
|
||||
|
||||
it("leaves an axis with real headroom untouched", () => {
|
||||
// Its minimum sits well clear of the tick below it, so the rounding it
|
||||
// already gets is enough and the widened extent floors to the same place.
|
||||
const data = [18.3, 21.2, 19.5];
|
||||
expect(renderExtent(data, withGap())).toEqual(
|
||||
renderExtent(data, { scale: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a non-negative series anchored at zero", () => {
|
||||
expect(renderExtent([0, 3500, 1200], withGap())[0]).toBe(0);
|
||||
// Without the clamp the nudged minimum floors a whole interval below zero.
|
||||
expect(
|
||||
renderExtent([0, 3500, 1200], {
|
||||
scale: true,
|
||||
boundaryGap: [0.02, 0.02],
|
||||
})[0]
|
||||
).toBe(-1000);
|
||||
});
|
||||
|
||||
it("keeps the window ECharts gives a constant series", () => {
|
||||
expect(renderExtent([21, 21, 21], withGap())).toEqual(
|
||||
renderExtent([21, 21, 21], { scale: true })
|
||||
);
|
||||
// A series flat at zero has no magnitude to expand by.
|
||||
expect(renderExtent([0, 0, 0], withGap())).toEqual(
|
||||
renderExtent([0, 0, 0], { scale: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not round a percentage axis past 100", () => {
|
||||
const battery = [20, 100, 60, 20, 80];
|
||||
expect(renderExtent(battery, withGap())).toEqual([0, 120]);
|
||||
expect(renderExtent(battery, withGap(false, "%"))).toEqual([0, 100]);
|
||||
});
|
||||
|
||||
it("re-anchors at zero when a chart switches to a zero-anchored type", () => {
|
||||
// setOption merges the Y axis, so a gap that is only emitted conditionally
|
||||
// survives the switch and leaves the bars floating.
|
||||
expect(renderExtent([20, 500, 300], withGap(), withGap(true))[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -72,13 +72,25 @@ describe("computeYAxisFractionDigits", () => {
|
||||
});
|
||||
|
||||
describe("createYAxisPrecisionBounds", () => {
|
||||
it("computes digits from the visible extent when no bounds are set", () => {
|
||||
const makeBounds = (
|
||||
options: Omit<
|
||||
Parameters<typeof createYAxisPrecisionBounds>[0],
|
||||
"onFractionDigits"
|
||||
> = {}
|
||||
) => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({ onFractionDigits });
|
||||
return {
|
||||
...createYAxisPrecisionBounds({ ...options, onFractionDigits }),
|
||||
onFractionDigits,
|
||||
};
|
||||
};
|
||||
|
||||
it("computes digits from the visible extent when no bounds are set", () => {
|
||||
const { min, max, onFractionDigits } = makeBounds();
|
||||
|
||||
// Zoomed-out extent -> coarse precision, callbacks leave scaling to ECharts
|
||||
expect(min({ min: 0, max: 100 })).toBeUndefined();
|
||||
expect(max({ min: 0, max: 100 })).toBeUndefined();
|
||||
expect(min({ min: 10, max: 100 })).toBeUndefined();
|
||||
expect(max({ min: 10, max: 100 })).toBeUndefined();
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
|
||||
|
||||
// Zoomed-in narrow extent -> more decimals so ticks stay distinct
|
||||
@@ -87,12 +99,7 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("computes digits from numeric bounds and returns them unchanged", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({
|
||||
min: 1.85,
|
||||
max: 2,
|
||||
onFractionDigits,
|
||||
});
|
||||
const { min, max, onFractionDigits } = makeBounds({ min: 1.85, max: 2 });
|
||||
|
||||
// Fixed bounds pin the range, so the visible extent is ignored
|
||||
expect(min({ min: 1.9, max: 1.95 })).toBe(1.85);
|
||||
@@ -101,11 +108,9 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("resolves function bounds and passes their result through", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({
|
||||
const { min, max, onFractionDigits } = makeBounds({
|
||||
min: ({ min: dataMin }) => dataMin - 1,
|
||||
max: ({ max: dataMax }) => dataMax + 1,
|
||||
onFractionDigits,
|
||||
});
|
||||
|
||||
expect(min({ min: 10, max: 11 })).toBe(9);
|
||||
@@ -115,11 +120,7 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("unions the extent with zero for anchored axes", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min } = createYAxisPrecisionBounds({
|
||||
includeZero: true,
|
||||
onFractionDigits,
|
||||
});
|
||||
const { min, onFractionDigits } = makeBounds({ includeZero: true });
|
||||
|
||||
// Data sits at 20..25, but a bar axis renders from 0 -> coarse precision
|
||||
min({ min: 20, max: 25 });
|
||||
@@ -131,10 +132,102 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("does not over-pad when the visible extent collapses to noise", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min } = createYAxisPrecisionBounds({ onFractionDigits });
|
||||
const { min, onFractionDigits } = makeBounds();
|
||||
|
||||
min({ min: 0.3, max: 0.3 + 1e-15 });
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
|
||||
});
|
||||
|
||||
it("widens the extent so ECharts always rounds out a gap", () => {
|
||||
const { min, max, boundaryGap, splitNumber } = makeBounds();
|
||||
|
||||
// The gap is applied by ECharts, so the bounds stay auto-scaled.
|
||||
expect(boundaryGap).toEqual([0.02, 0.02]);
|
||||
// Pinned rather than assumed, since the precision here is derived from it.
|
||||
expect(splitNumber).toBe(5);
|
||||
expect(min({ min: 18, max: 21.2 })).toBeUndefined();
|
||||
expect(max({ min: 18, max: 21.2 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("anchors at zero instead of widening a non-negative series below it", () => {
|
||||
const { min, max } = makeBounds();
|
||||
|
||||
// Without this, ECharts floors the widened -0.0035 to a full interval below
|
||||
// zero, inventing a negative region on a power chart.
|
||||
expect(min({ min: 0, max: 3500 })).toBe(0);
|
||||
expect(max({ min: 0, max: 3500 })).toBeUndefined();
|
||||
|
||||
// A minimum clear of the gap is left to ECharts.
|
||||
expect(min({ min: 100, max: 3500 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("anchors an all-negative series at zero from above", () => {
|
||||
const { min, max } = makeBounds();
|
||||
|
||||
expect(max({ min: -3500, max: 0 })).toBe(0);
|
||||
expect(min({ min: -3500, max: 0 })).toBeUndefined();
|
||||
|
||||
// Mixed-sign data is widened at both ends.
|
||||
expect(min({ min: -20, max: 20 })).toBeUndefined();
|
||||
expect(max({ min: -20, max: 20 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the expanded window ECharts gives a constant series", () => {
|
||||
const { min, max, onFractionDigits } = makeBounds();
|
||||
|
||||
// ECharts only expands a flat extent while its bounds are equal, so the gap
|
||||
// would otherwise collapse this to 20.99998..21.00002 at five decimals.
|
||||
expect(min({ min: 21, max: 21 })).toBe(10);
|
||||
expect(max({ min: 21, max: 21 })).toBe(35);
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
|
||||
|
||||
// A series flat at zero has no magnitude to expand by.
|
||||
expect(min({ min: 0, max: 0 })).toBe(0);
|
||||
expect(max({ min: 0, max: 0 })).toBe(1);
|
||||
});
|
||||
|
||||
it("stops a percentage axis at 100 the way it stops at zero", () => {
|
||||
const { max } = makeBounds({ unit: "%" });
|
||||
|
||||
// A battery that reaches full would otherwise round out to an axis
|
||||
// labelled up to 120%.
|
||||
expect(max({ min: 20, max: 100 })).toBe(100);
|
||||
expect(max({ min: 20, max: 99 })).toBe(100);
|
||||
|
||||
// Well clear of the ceiling, so it still gets its gap.
|
||||
expect(max({ min: 40, max: 60 })).toBeUndefined();
|
||||
|
||||
// Not every % sensor is bounded: power factor is signed and can read over
|
||||
// 100, and a series already past the ceiling must be left alone.
|
||||
expect(max({ min: -80, max: 140 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("holds a constant percentage under the ceiling too", () => {
|
||||
const { max } = makeBounds({ unit: "%" });
|
||||
|
||||
// A device left on the charger reads a flat 100%, and the expansion a flat
|
||||
// series gets is a fraction of its magnitude, so it overshoots on its own.
|
||||
expect(max({ min: 100, max: 100 })).toBe(100);
|
||||
expect(max({ min: 80, max: 80 })).toBe(100);
|
||||
|
||||
// Far enough below that the expansion never reaches the ceiling.
|
||||
expect(max({ min: 54, max: 54 })).toBe(90);
|
||||
});
|
||||
|
||||
it("only treats a percentage as bounded", () => {
|
||||
// The same extent without the unit is widened as usual.
|
||||
expect(makeBounds().max({ min: 20, max: 100 })).toBeUndefined();
|
||||
expect(
|
||||
makeBounds({ unit: "W" }).max({ min: 20, max: 100 })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never widens a zero-anchored axis", () => {
|
||||
const { min, max, boundaryGap } = makeBounds({ includeZero: true });
|
||||
|
||||
// Widening below zero would defeat ECharts' anchoring and float the bars.
|
||||
expect(boundaryGap).toEqual([0, 0]);
|
||||
expect(min({ min: 0, max: 3500 })).toBeUndefined();
|
||||
expect(max({ min: 20, max: 20 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,4 +173,31 @@ describe("ha-target-picker-item-row target extraction", () => {
|
||||
expect(entries!.referenced_devices).toEqual(["dev_1"]);
|
||||
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
|
||||
});
|
||||
|
||||
it("does not mutate the extracted target result", async () => {
|
||||
const result = extractResult({
|
||||
referenced_areas: ["area_missing"],
|
||||
referenced_devices: ["dev_missing"],
|
||||
referenced_entities: ["light.missing"],
|
||||
});
|
||||
|
||||
const entries = await extractedBy(
|
||||
result,
|
||||
{
|
||||
areas: {},
|
||||
devices: {},
|
||||
entities: {},
|
||||
},
|
||||
{ type: "floor", itemId: "floor_1" }
|
||||
);
|
||||
|
||||
expect(entries).toBeDefined();
|
||||
expect(entries).not.toBe(result);
|
||||
expect(entries!.referenced_areas).toEqual([]);
|
||||
expect(entries!.referenced_devices).toEqual([]);
|
||||
expect(entries!.referenced_entities).toEqual([]);
|
||||
expect(result.referenced_areas).toEqual(["area_missing"]);
|
||||
expect(result.referenced_devices).toEqual(["dev_missing"]);
|
||||
expect(result.referenced_entities).toEqual(["light.missing"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DeviceTrigger } from "../../src/data/device/device_automation";
|
||||
import {
|
||||
fetchReplacementDevices,
|
||||
findEquivalentDeviceAutomation,
|
||||
} from "../../src/data/device/device_automation";
|
||||
import type { DeviceCompositeSplits } from "../../src/data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../src/data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
const entityRegistry = [
|
||||
{ id: "regid1", entity_id: "binary_sensor.one" },
|
||||
{ id: "regid2", entity_id: "binary_sensor.two" },
|
||||
] as EntityRegistryEntry[];
|
||||
|
||||
const trigger = (partial: Partial<DeviceTrigger>): DeviceTrigger =>
|
||||
({
|
||||
trigger: "device",
|
||||
domain: "binary_sensor",
|
||||
device_id: "device1",
|
||||
...partial,
|
||||
}) as DeviceTrigger;
|
||||
|
||||
describe("findEquivalentDeviceAutomation", () => {
|
||||
it("picks the automation on the same entity among several of the same type", () => {
|
||||
const automations = [
|
||||
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
|
||||
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
findEquivalentDeviceAutomation(
|
||||
entityRegistry,
|
||||
automations,
|
||||
trigger({ type: "turned_on", entity_id: "regid2" })
|
||||
)
|
||||
).toBe(automations[1]);
|
||||
});
|
||||
|
||||
it("matches an entity referenced by entity id against one referenced by registry id", () => {
|
||||
const automations = [
|
||||
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
|
||||
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
findEquivalentDeviceAutomation(
|
||||
entityRegistry,
|
||||
automations,
|
||||
trigger({ type: "turned_on", entity_id: "binary_sensor.two" })
|
||||
)
|
||||
).toBe(automations[1]);
|
||||
});
|
||||
|
||||
it("returns undefined when the same type is only offered for another entity", () => {
|
||||
const automations = [
|
||||
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
|
||||
trigger({
|
||||
device_id: "device2",
|
||||
type: "turned_off",
|
||||
entity_id: "regid1",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
findEquivalentDeviceAutomation(
|
||||
entityRegistry,
|
||||
automations,
|
||||
trigger({ type: "turned_on", entity_id: "regid2" })
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches entity-less automations on their subtype", () => {
|
||||
const automations = [
|
||||
trigger({
|
||||
device_id: "device2",
|
||||
domain: "zha",
|
||||
type: "remote_button_short_press",
|
||||
subtype: "button_1",
|
||||
}),
|
||||
trigger({
|
||||
device_id: "device2",
|
||||
domain: "zha",
|
||||
type: "remote_button_short_press",
|
||||
subtype: "button_2",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
findEquivalentDeviceAutomation(
|
||||
entityRegistry,
|
||||
automations,
|
||||
trigger({
|
||||
domain: "zha",
|
||||
type: "remote_button_short_press",
|
||||
subtype: "button_2",
|
||||
})
|
||||
)
|
||||
).toBe(automations[1]);
|
||||
});
|
||||
|
||||
it("returns undefined when the device offers no automation of that type", () => {
|
||||
const automations = [
|
||||
trigger({
|
||||
device_id: "device2",
|
||||
type: "turned_off",
|
||||
entity_id: "regid1",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
findEquivalentDeviceAutomation(
|
||||
entityRegistry,
|
||||
automations,
|
||||
trigger({ type: "turned_on", entity_id: "regid1" })
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchReplacementDevices", () => {
|
||||
const hass = {
|
||||
callWS: () => Promise.resolve([]),
|
||||
devices: { device2: {}, device3: {} },
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const compositeSplits = {
|
||||
removed: { split_ids: ["device2", "device3"], primary_id: "device2" },
|
||||
} as unknown as DeviceCompositeSplits;
|
||||
|
||||
const value = trigger({
|
||||
device_id: "removed",
|
||||
type: "turned_on",
|
||||
entity_id: "regid1",
|
||||
});
|
||||
|
||||
it("keeps only the devices that offer the automation", async () => {
|
||||
const offers = {
|
||||
device2: [
|
||||
trigger({
|
||||
device_id: "device2",
|
||||
type: "turned_on",
|
||||
entity_id: "regid1",
|
||||
}),
|
||||
],
|
||||
device3: [
|
||||
trigger({
|
||||
device_id: "device3",
|
||||
type: "turned_on",
|
||||
entity_id: "regid2",
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
await fetchReplacementDevices(
|
||||
hass,
|
||||
entityRegistry,
|
||||
value,
|
||||
compositeSplits,
|
||||
(_callWS, deviceId) => Promise.resolve(offers[deviceId])
|
||||
)
|
||||
).toEqual(["device2"]);
|
||||
});
|
||||
|
||||
it("drops a device whose automations cannot be listed", async () => {
|
||||
expect(
|
||||
await fetchReplacementDevices(
|
||||
hass,
|
||||
entityRegistry,
|
||||
value,
|
||||
compositeSplits,
|
||||
(_callWS, deviceId) =>
|
||||
deviceId === "device2"
|
||||
? Promise.reject(new Error("unknown device"))
|
||||
: Promise.resolve([
|
||||
trigger({
|
||||
device_id: "device3",
|
||||
type: "turned_on",
|
||||
entity_id: "regid1",
|
||||
}),
|
||||
])
|
||||
)
|
||||
).toEqual(["device3"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// MapLibre hands tile URLs to a worker, which has no document to resolve a
|
||||
// relative URL against. Measured: without absolute URLs nothing loads at all -
|
||||
// not the TileJSON, not the glyphs, not a tile - and MapLibre reports no error,
|
||||
// so this is worth pinning down.
|
||||
|
||||
const connectionWith = (token: string) =>
|
||||
({
|
||||
sendMessagePromise: vi.fn(async () => ({ token })),
|
||||
}) as unknown as Connection;
|
||||
|
||||
const load = async () => {
|
||||
vi.resetModules();
|
||||
return import("../../src/data/map_tiles");
|
||||
};
|
||||
|
||||
describe("withMapTilesToken", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("makes relative URLs absolute", async () => {
|
||||
const { withMapTilesToken } = await load();
|
||||
|
||||
expect(withMapTilesToken("/api/map_tiles/tilejson.json")).toBe(
|
||||
`${location.origin}/api/map_tiles/tilejson.json`
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the token to proxy URLs once there is one", async () => {
|
||||
const { ensureMapTilesToken, withMapTilesToken } = await load();
|
||||
await ensureMapTilesToken(connectionWith("abc123"));
|
||||
|
||||
const url = new URL(withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt"));
|
||||
expect(url.searchParams.get("token")).toBe("abc123");
|
||||
expect(url.pathname).toBe("/api/map_tiles/vector/1/0/0.mvt");
|
||||
});
|
||||
|
||||
it("leaves anything outside the proxy alone", async () => {
|
||||
const { ensureMapTilesToken, withMapTilesToken } = await load();
|
||||
await ensureMapTilesToken(connectionWith("abc123"));
|
||||
|
||||
const url = new URL(
|
||||
withMapTilesToken("https://example.com/tiles/1/0/0.png")
|
||||
);
|
||||
expect(url.searchParams.get("token")).toBeNull();
|
||||
expect(url.href).toBe("https://example.com/tiles/1/0/0.png");
|
||||
});
|
||||
|
||||
it("still returns an absolute URL when there is no token", async () => {
|
||||
const { withMapTilesToken } = await load();
|
||||
|
||||
expect(
|
||||
withMapTilesToken("/api/map_tiles/fonts/noto_sans_regular/0-255.pbf")
|
||||
).toBe(
|
||||
`${location.origin}/api/map_tiles/fonts/noto_sans_regular/0-255.pbf`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureMapTilesToken", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("asks the backend once and reuses the answer", async () => {
|
||||
const { ensureMapTilesToken } = await load();
|
||||
const connection = connectionWith("abc123");
|
||||
|
||||
expect(await ensureMapTilesToken(connection)).toBe("abc123");
|
||||
expect(await ensureMapTilesToken(connection)).toBe("abc123");
|
||||
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A backend without the proxy must not hold the map hostage while the
|
||||
// retries run; the map falls back to loading nothing rather than to nothing
|
||||
// being drawn at all.
|
||||
it("gives up quickly on a backend that does not answer", async () => {
|
||||
const { ensureMapTilesToken } = await load();
|
||||
const connection = {
|
||||
sendMessagePromise: vi.fn(async () => {
|
||||
throw new Error("unknown command");
|
||||
}),
|
||||
} as unknown as Connection;
|
||||
|
||||
const started = Date.now();
|
||||
expect(await ensureMapTilesToken(connection)).toBeUndefined();
|
||||
expect(Date.now() - started).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
it("tells subscribers when a token arrives, for Leaflet's baked template", async () => {
|
||||
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
|
||||
const listener = vi.fn();
|
||||
subscribeMapTilesToken(listener);
|
||||
|
||||
await ensureMapTilesToken(connectionWith("abc123"));
|
||||
|
||||
expect(listener).toHaveBeenCalledWith("abc123");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { getStatisticIds } from "../../src/data/recorder_statistic_ids";
|
||||
import type { StatisticsMetaData } from "../../src/data/recorder";
|
||||
|
||||
const createHass = (callWS = vi.fn()) =>
|
||||
({
|
||||
callWS,
|
||||
}) as unknown as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
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("getStatisticIds", () => {
|
||||
it("shares concurrent requests for the same statistic type", async () => {
|
||||
const request = deferred<StatisticsMetaData[]>();
|
||||
const callWS = vi.fn().mockReturnValue(request.promise);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
const first = getStatisticIds(hass);
|
||||
const second = getStatisticIds(hass);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
request.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("works with callers that only expose callWS", async () => {
|
||||
const request = deferred<StatisticsMetaData[]>();
|
||||
const callWS = vi.fn().mockReturnValue(request.promise);
|
||||
const api = { callWS } as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const first = getStatisticIds(api);
|
||||
const second = getStatisticIds(api);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
request.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("shares requests across callers with the same callWS", async () => {
|
||||
const request = deferred<StatisticsMetaData[]>();
|
||||
const callWS = vi.fn().mockReturnValue(request.promise);
|
||||
const firstHass = createHass(callWS);
|
||||
const secondHass = createHass(callWS);
|
||||
|
||||
const first = getStatisticIds(firstHass);
|
||||
const second = getStatisticIds(secondHass);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
request.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("does not share requests for different statistic types", async () => {
|
||||
const callWS = vi.fn().mockResolvedValue([]);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
await Promise.all([
|
||||
getStatisticIds(hass),
|
||||
getStatisticIds(hass, "mean"),
|
||||
getStatisticIds(hass, "sum"),
|
||||
]);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(3);
|
||||
expect(callWS).toHaveBeenNthCalledWith(1, {
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type: undefined,
|
||||
});
|
||||
expect(callWS).toHaveBeenNthCalledWith(2, {
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type: "mean",
|
||||
});
|
||||
expect(callWS).toHaveBeenNthCalledWith(3, {
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type: "sum",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not share requests across different callWS owners", async () => {
|
||||
const firstRequest = deferred<StatisticsMetaData[]>();
|
||||
const secondRequest = deferred<StatisticsMetaData[]>();
|
||||
|
||||
const firstCallWS = vi.fn().mockReturnValue(firstRequest.promise);
|
||||
const secondCallWS = vi.fn().mockReturnValue(secondRequest.promise);
|
||||
|
||||
const first = getStatisticIds(createHass(firstCallWS));
|
||||
const second = getStatisticIds(createHass(secondCallWS));
|
||||
|
||||
expect(firstCallWS).toHaveBeenCalledTimes(1);
|
||||
expect(secondCallWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).not.toBe(first);
|
||||
|
||||
firstRequest.resolve([]);
|
||||
secondRequest.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("fetches again after the previous request settles", async () => {
|
||||
const callWS = vi.fn().mockResolvedValue([]);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
await getStatisticIds(hass, "sum");
|
||||
await getStatisticIds(hass, "sum");
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries after a failed request", async () => {
|
||||
const callWS = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("failed"))
|
||||
.mockResolvedValueOnce([]);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
await expect(getStatisticIds(hass)).rejects.toThrow("failed");
|
||||
await getStatisticIds(hass);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { extractFromTarget } from "../../src/data/target";
|
||||
|
||||
type CallWS = Parameters<typeof extractFromTarget>[0];
|
||||
|
||||
const callWS = () => vi.fn().mockResolvedValue({}) as unknown as CallWS;
|
||||
|
||||
describe("extractFromTarget in-flight sharing", () => {
|
||||
it("shares identical concurrent requests", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, target),
|
||||
extractFromTarget(ws, target),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share different targets", async () => {
|
||||
const ws = callWS();
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, { area_id: ["bathroom"] }),
|
||||
extractFromTarget(ws, { area_id: ["kitchen"] }),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share different expandGroup values", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, target, false),
|
||||
extractFromTarget(ws, target, true),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share different primaryEntitiesOnly values", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, target, false, true),
|
||||
extractFromTarget(ws, target, false, false),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share between different callWS owners", async () => {
|
||||
const first = callWS();
|
||||
const second = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(first, target),
|
||||
extractFromTarget(second, target),
|
||||
]);
|
||||
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fetches again after the request settles", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await extractFromTarget(ws, target);
|
||||
await extractFromTarget(ws, target);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { deepActiveElement } from "../../../src/common/dom/deep-active-element";
|
||||
import { nextRender } from "../../../src/common/util/render-status";
|
||||
import type {
|
||||
FormDialogData,
|
||||
FormDialogParams,
|
||||
@@ -146,8 +147,13 @@ const submit = (dialog: DialogForm) =>
|
||||
const cancel = (dialog: DialogForm) =>
|
||||
(getInternals(dialog)["_cancel"] as () => void)();
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
mockForm.delayedTag = undefined;
|
||||
document.body.querySelectorAll("dialog-form").forEach((el) => {
|
||||
(el as DialogForm).closeDialog();
|
||||
});
|
||||
// Drain the fire-and-forget focus restore before jsdom teardown.
|
||||
await nextRender();
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
+89
-24
@@ -14,6 +14,7 @@ import {
|
||||
defineRouteSmokeTests,
|
||||
ensureAppSidebarPanelVisible,
|
||||
goToPanel,
|
||||
openMoreInfoDialog,
|
||||
} from "./app/src/helpers";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
@@ -370,34 +371,16 @@ test.describe("Light more-info dialog", () => {
|
||||
// The light-more-info scenario seeds light.test_light synchronously.
|
||||
await goToPanel(page, "/?scenario=light-more-info#/lovelace");
|
||||
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
|
||||
// Fire the standard hass-more-info event from the app root with an
|
||||
// explicit view. The HA shell opens ha-more-info-dialog on the requested
|
||||
// view directly, so the test does not depend on the admin/demo-hidden
|
||||
// header controls.
|
||||
//
|
||||
// The event is one-shot: if it lands before the shell's hass-more-info
|
||||
// listener is attached it is silently dropped. Re-dispatching is
|
||||
// idempotent (showDialog just resets the dialog to the requested view),
|
||||
// so poll the dispatch until the requested view actually renders.
|
||||
await expect(async () => {
|
||||
await page.evaluate((v) => {
|
||||
const el = document.querySelector("ha-test");
|
||||
el?.dispatchEvent(
|
||||
new CustomEvent("hass-more-info", {
|
||||
detail: { entityId: "light.test_light", view: v },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}, view);
|
||||
|
||||
await expect(dialog).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(dialog.locator(element)).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
}).toPass({ timeout: SHELL_TIMEOUT });
|
||||
const dialog = await openMoreInfoDialog(
|
||||
page,
|
||||
"light.test_light",
|
||||
view,
|
||||
element
|
||||
);
|
||||
|
||||
// Each view should render its own characteristic content, not just an
|
||||
// empty shell.
|
||||
@@ -464,6 +447,88 @@ test.describe("Weather more-info deep link", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("More-info dialog URL cleanup", () => {
|
||||
test("strips the deep-link params on a plain close", async ({ page }) => {
|
||||
const errors = trackPageErrors(page);
|
||||
|
||||
// An exact route so no default-page redirect races the dialog open.
|
||||
await goToPanel(page, "/?scenario=light-more-info#/config/dashboard");
|
||||
|
||||
const dialog = await openMoreInfoDialog(page, "light.test_light");
|
||||
|
||||
await expect(page).toHaveURL(/more-info-entity-id=light\.test_light/);
|
||||
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
|
||||
// The dialog re-renders empty once its close cleanup has run.
|
||||
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(0, {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
|
||||
await expect(page).not.toHaveURL(/more-info-entity-id/);
|
||||
await expect(page).toHaveURL(/#\/config\/dashboard/);
|
||||
expectNoPageErrors(errors);
|
||||
});
|
||||
|
||||
test.describe("when navigation closes the dialog", () => {
|
||||
// --ha-dialog-hide-duration only applies in dialog mode; the bottom sheet
|
||||
// hardcodes its animation duration, so pin a desktop viewport on every
|
||||
// project to keep the slow-close setup below effective.
|
||||
test.use({ viewport: { width: 1280, height: 800 } });
|
||||
|
||||
test("keeps the new URL when navigation outpaces the close transition", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors = trackPageErrors(page);
|
||||
|
||||
// An exact route so no default-page redirect races the dialog open.
|
||||
await goToPanel(page, "/?scenario=light-more-info#/config/dashboard");
|
||||
|
||||
const dialog = await openMoreInfoDialog(page, "light.test_light");
|
||||
|
||||
await expect(page).toHaveURL(/more-info-entity-id=light\.test_light/);
|
||||
|
||||
// Make the hide transition outlast navigate()'s dialog-close wait so
|
||||
// the navigation commits its URL while the dialog is still closing,
|
||||
// like on a slow device or with a long themed animation.
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--ha-dialog-hide-duration",
|
||||
"1200ms"
|
||||
);
|
||||
});
|
||||
|
||||
// Navigate through a synthetic same-origin link: the dialog scrim
|
||||
// blocks real link clicks and the dialog's own edit/device actions are
|
||||
// hidden in the demo build, while navigate() closes open dialogs the
|
||||
// same way for all of them.
|
||||
await page.evaluate(() => {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = "/history";
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/#\/history/, { timeout: QUICK_TIMEOUT });
|
||||
|
||||
// The navigation must commit while the dialog is still closing,
|
||||
// otherwise this test no longer covers the regression.
|
||||
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(1);
|
||||
|
||||
// The dialog re-renders empty once its close cleanup has run.
|
||||
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(0, {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
|
||||
// The cleanup must not rewrite the URL back to the pre-dialog page.
|
||||
await expect(page).toHaveURL(/#\/history/);
|
||||
await expect(page).not.toHaveURL(/more-info-entity-id/);
|
||||
expectNoPageErrors(errors);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -27,6 +27,38 @@ export async function goToPanel(page: Page, path: string) {
|
||||
]);
|
||||
}
|
||||
|
||||
// The hass-more-info event is one-shot: if it lands before the shell's
|
||||
// listener is attached it is silently dropped. Re-dispatching is idempotent
|
||||
// (showDialog just resets the dialog to the requested view), so poll the
|
||||
// dispatch until the requested view actually renders.
|
||||
export async function openMoreInfoDialog(
|
||||
page: Page,
|
||||
entityId: string,
|
||||
view?: string,
|
||||
readyElement = "ha-more-info-info"
|
||||
) {
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
await expect(async () => {
|
||||
await page.evaluate(
|
||||
(detail) => {
|
||||
document.querySelector("ha-test")?.dispatchEvent(
|
||||
new CustomEvent("hass-more-info", {
|
||||
detail,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
},
|
||||
view ? { entityId, view } : { entityId }
|
||||
);
|
||||
await expect(dialog).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(dialog.locator(readyElement)).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
}).toPass({ timeout: SHELL_TIMEOUT });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
export const appMain = (page: Page) => page.locator(APP_MAIN_SELECTOR);
|
||||
|
||||
export const appSidebar = (page: Page) => page.locator(APP_SIDEBAR_SELECTOR);
|
||||
|
||||
@@ -100,6 +100,7 @@ const commandResults: Record<string, unknown> = {
|
||||
},
|
||||
"lovelace/info": { resource_mode: "storage" },
|
||||
"lovelace/resources": [],
|
||||
"map_tiles/access_token": { token: "map-tiles-token" },
|
||||
"recorder/info": {
|
||||
migration_in_progress: false,
|
||||
migration_is_live: false,
|
||||
@@ -177,6 +178,13 @@ export async function setupOnboardingMocks(
|
||||
): Promise<OnboardingCalls> {
|
||||
const calls: OnboardingCalls = { tokenRequests: [] };
|
||||
|
||||
// The location step shows a map, which asks core's tile proxy for tiles core
|
||||
// is not running here. Answer them so the outcome does not depend on what the
|
||||
// dev server does with an unknown /api path.
|
||||
await page.route("**/api/map_tiles/**", (route) =>
|
||||
route.fulfill({ status: 404, body: "" })
|
||||
);
|
||||
|
||||
await page.route("**/api/onboarding**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
@@ -120,6 +120,50 @@ class RaceRouter extends HassRouterPage {
|
||||
}
|
||||
}
|
||||
|
||||
class PathPanel extends HTMLElement {
|
||||
public itemId = "";
|
||||
}
|
||||
|
||||
class DashPanel extends HTMLElement {}
|
||||
|
||||
class PathRouter extends HassRouterPage {
|
||||
public editLoads = 0;
|
||||
|
||||
protected routerOptions: RouterOptions = {
|
||||
routes: {
|
||||
dashboard: { tag: "test-dash-panel", cache: true },
|
||||
edit: {
|
||||
tag: "test-path-panel",
|
||||
cache: true,
|
||||
itemId: true,
|
||||
load: () => {
|
||||
this.editLoads += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
view: { tag: "test-path-panel", cache: true },
|
||||
},
|
||||
};
|
||||
|
||||
protected updatePageEl(el: PathPanel) {
|
||||
if (this._currentPage === "edit" || this._currentPage === "view") {
|
||||
el.itemId = this.routeTail.path.slice(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ParentRouter extends HassRouterPage {
|
||||
protected routerOptions: RouterOptions = {
|
||||
routes: {
|
||||
automation: { tag: "test-path-router" },
|
||||
},
|
||||
};
|
||||
|
||||
protected updatePageEl(el: PathRouter) {
|
||||
el.route = this.routeTail;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-router", TestRouter);
|
||||
customElements.define("test-immediate-panel", ImmediatePanel);
|
||||
customElements.define("test-deferred-panel", DeferredPanel);
|
||||
@@ -129,6 +173,10 @@ customElements.define("test-swap-router", SwapRouter);
|
||||
customElements.define("test-swap-first-panel", SwapFirstPanel);
|
||||
customElements.define("test-swap-second-panel", SwapSecondPanel);
|
||||
customElements.define("test-race-router", RaceRouter);
|
||||
customElements.define("test-path-panel", PathPanel);
|
||||
customElements.define("test-dash-panel", DashPanel);
|
||||
customElements.define("test-path-router", PathRouter);
|
||||
customElements.define("test-parent-router", ParentRouter);
|
||||
|
||||
let router: TestRouter | undefined;
|
||||
|
||||
@@ -294,6 +342,151 @@ describe("HassRouterPage update propagation during load", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("HassRouterPage item ids", () => {
|
||||
it("recreates the page when the item id changes", async () => {
|
||||
const element = document.createElement("test-path-router") as PathRouter;
|
||||
element.route = { prefix: "/config/automation", path: "/edit/a" };
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const first = element.lastElementChild as PathPanel;
|
||||
expect(first.itemId).toBe("a");
|
||||
expect(element.editLoads).toBe(1);
|
||||
|
||||
element.route = { prefix: "/config/automation", path: "/edit/b" };
|
||||
await element.updateComplete;
|
||||
|
||||
const second = element.lastElementChild as PathPanel;
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.itemId).toBe("b");
|
||||
expect(first.isConnected).toBe(false);
|
||||
expect(element.editLoads).toBe(2);
|
||||
|
||||
element.remove();
|
||||
});
|
||||
|
||||
it("does not cache a page that has an item id", async () => {
|
||||
const element = document.createElement("test-path-router") as PathRouter;
|
||||
element.route = { prefix: "/config/automation", path: "/edit/a" };
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const first = element.lastElementChild as PathPanel;
|
||||
element.route = { prefix: "/config/automation", path: "/dashboard" };
|
||||
await element.updateComplete;
|
||||
element.route = { prefix: "/config/automation", path: "/edit/a" };
|
||||
await element.updateComplete;
|
||||
|
||||
const second = element.lastElementChild as PathPanel;
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.itemId).toBe("a");
|
||||
|
||||
element.remove();
|
||||
});
|
||||
|
||||
it("still caches pages that have no item id", async () => {
|
||||
const element = document.createElement("test-path-router") as PathRouter;
|
||||
element.route = { prefix: "/config/automation", path: "/dashboard" };
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const first = element.lastElementChild as DashPanel;
|
||||
element.route = { prefix: "/config/automation", path: "/edit/a" };
|
||||
await element.updateComplete;
|
||||
element.route = { prefix: "/config/automation", path: "/dashboard" };
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.lastElementChild).toBe(first);
|
||||
|
||||
element.remove();
|
||||
});
|
||||
|
||||
it("keeps the same page when the item id is unchanged", async () => {
|
||||
const element = document.createElement("test-path-router") as PathRouter;
|
||||
element.route = { prefix: "/config/automation", path: "/edit/a" };
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const first = element.lastElementChild as PathPanel;
|
||||
element.route = { prefix: "/config/automation", path: "/edit/a" };
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.lastElementChild).toBe(first);
|
||||
expect(first.itemId).toBe("a");
|
||||
|
||||
element.remove();
|
||||
});
|
||||
|
||||
it("reuses a page with a one-segment tail when itemId is not set", async () => {
|
||||
const element = document.createElement("test-path-router") as PathRouter;
|
||||
element.route = { prefix: "/lovelace", path: "/view/0" };
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const first = element.lastElementChild as PathPanel;
|
||||
expect(first.itemId).toBe("0");
|
||||
|
||||
element.route = { prefix: "/lovelace", path: "/view/1" };
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.lastElementChild).toBe(first);
|
||||
expect(first.itemId).toBe("1");
|
||||
|
||||
element.remove();
|
||||
});
|
||||
|
||||
it("does not recreate a nested router when opening an item from the dashboard", async () => {
|
||||
const element = document.createElement(
|
||||
"test-parent-router"
|
||||
) as ParentRouter;
|
||||
element.route = { prefix: "/config", path: "/automation/dashboard" };
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const nested = element.lastElementChild as PathRouter;
|
||||
expect(nested).toBeInstanceOf(PathRouter);
|
||||
await nested.updateComplete;
|
||||
const dashboard = nested.lastElementChild as DashPanel;
|
||||
expect(dashboard).toBeInstanceOf(DashPanel);
|
||||
|
||||
element.route = { prefix: "/config", path: "/automation/edit/a" };
|
||||
await element.updateComplete;
|
||||
await nested.updateComplete;
|
||||
|
||||
expect(element.lastElementChild).toBe(nested);
|
||||
const editor = nested.lastElementChild as PathPanel;
|
||||
expect(editor.itemId).toBe("a");
|
||||
|
||||
element.remove();
|
||||
});
|
||||
|
||||
it("does not recreate a nested router when only its child's id changes", async () => {
|
||||
const element = document.createElement(
|
||||
"test-parent-router"
|
||||
) as ParentRouter;
|
||||
element.route = { prefix: "/config", path: "/automation/edit/a" };
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const nested = element.lastElementChild as PathRouter;
|
||||
expect(nested).toBeInstanceOf(PathRouter);
|
||||
await nested.updateComplete;
|
||||
const firstLeaf = nested.lastElementChild as PathPanel;
|
||||
expect(firstLeaf.itemId).toBe("a");
|
||||
|
||||
element.route = { prefix: "/config", path: "/automation/edit/b" };
|
||||
await element.updateComplete;
|
||||
await nested.updateComplete;
|
||||
|
||||
expect(element.lastElementChild).toBe(nested);
|
||||
const secondLeaf = nested.lastElementChild as PathPanel;
|
||||
expect(secondLeaf).not.toBe(firstLeaf);
|
||||
expect(secondLeaf.itemId).toBe("b");
|
||||
|
||||
element.remove();
|
||||
});
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-router": TestRouter;
|
||||
@@ -306,5 +499,9 @@ declare global {
|
||||
"test-swap-first-panel": SwapFirstPanel;
|
||||
"test-swap-second-panel": SwapSecondPanel;
|
||||
"test-race-router": RaceRouter;
|
||||
"test-path-panel": PathPanel;
|
||||
"test-dash-panel": DashPanel;
|
||||
"test-path-router": PathRouter;
|
||||
"test-parent-router": ParentRouter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { navigate } from "../../src/common/navigate";
|
||||
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
|
||||
import { PreventUnsavedMixin } from "../../src/mixins/prevent-unsaved-mixin";
|
||||
|
||||
// navigate() closes open dialogs before touching history.
|
||||
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
|
||||
closeAllDialogs: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
interface TestState {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Mirrors the editors: DirtyStateProviderMixin wraps PreventUnsavedMixin.
|
||||
class TestPreventUnsaved extends DirtyStateProviderMixin<TestState>()(
|
||||
PreventUnsavedMixin(LitElement)
|
||||
) {
|
||||
public promptResponse = true;
|
||||
|
||||
public promptCalls = 0;
|
||||
|
||||
public initialize(value: TestState) {
|
||||
this._initDirtyTracking({ type: "shallow" }, value);
|
||||
}
|
||||
|
||||
public setValue(value: TestState) {
|
||||
this._updateDirtyState(value);
|
||||
}
|
||||
|
||||
public markClean() {
|
||||
this._markDirtyStateClean();
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges(): Promise<boolean> {
|
||||
this.promptCalls++;
|
||||
return this.promptResponse;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-prevent-unsaved", TestPreventUnsaved);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-prevent-unsaved": TestPreventUnsaved;
|
||||
}
|
||||
}
|
||||
|
||||
const setEntry = (path: string) => {
|
||||
window.history.replaceState(null, "", path);
|
||||
};
|
||||
|
||||
const mountClean = async () => {
|
||||
const element = document.createElement("test-prevent-unsaved");
|
||||
document.body.append(element);
|
||||
element.initialize({ name: "Kitchen" });
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
const mountDirty = async () => {
|
||||
const element = await mountClean();
|
||||
element.setValue({ name: "Bedroom" });
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
describe("PreventUnsavedMixin", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config/automation/edit/1234");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll("test-prevent-unsaved").forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks navigation while dirty when the prompt is declined", async () => {
|
||||
const element = await mountDirty();
|
||||
element.promptResponse = false;
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(false);
|
||||
|
||||
expect(element.promptCalls).toBe(1);
|
||||
expect(window.location.pathname).toEqual("/config/automation/edit/1234");
|
||||
});
|
||||
|
||||
it("navigates while dirty when the prompt is confirmed", async () => {
|
||||
const element = await mountDirty();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(1);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("does not prompt while clean", async () => {
|
||||
const element = await mountClean();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(0);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("does not prompt after changes are marked clean, before the next render", async () => {
|
||||
const element = await mountDirty();
|
||||
element.markClean();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(0);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("stops guarding after being disconnected", async () => {
|
||||
const element = await mountDirty();
|
||||
element.promptResponse = false;
|
||||
element.remove();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(0);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("arms beforeunload only while dirty", async () => {
|
||||
const element = await mountDirty();
|
||||
|
||||
let event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
|
||||
element.setValue({ name: "Kitchen" });
|
||||
await element.updateComplete;
|
||||
|
||||
event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { goBack, navigate } from "../../../../src/common/navigate";
|
||||
import type * as NavigateModule from "../../../../src/common/navigate";
|
||||
import "../../../../src/panels/config/automation/ha-automation-editor";
|
||||
import type { HaAutomationEditor } from "../../../../src/panels/config/automation/ha-automation-editor";
|
||||
import { createMockHass } from "../../../fixtures/hass";
|
||||
|
||||
vi.mock("../../../../src/common/navigate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NavigateModule>();
|
||||
return {
|
||||
...actual,
|
||||
goBack: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/dialogs/generic/show-dialog-box", () => ({
|
||||
showAlertDialog: vi.fn(async () => undefined),
|
||||
showConfirmationDialog: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
describe("automation editor disconnected load", () => {
|
||||
test("ignores a config fetch that 404s after the editor is disconnected", async () => {
|
||||
const el = document.createElement(
|
||||
"ha-automation-editor"
|
||||
) as HaAutomationEditor;
|
||||
const hass = createMockHass();
|
||||
let rejectLoad!: (reason: { status_code: number }) => void;
|
||||
(hass as any).callApi = vi.fn(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectLoad = reject;
|
||||
})
|
||||
);
|
||||
el.hass = hass;
|
||||
el.automations = [];
|
||||
|
||||
let connected = true;
|
||||
Object.defineProperty(el, "isConnected", {
|
||||
configurable: true,
|
||||
get: () => connected,
|
||||
});
|
||||
|
||||
const load = (el as any).loadConfig("missing");
|
||||
connected = false;
|
||||
rejectLoad({ status_code: 404 });
|
||||
await load;
|
||||
|
||||
expect(goBack).not.toHaveBeenCalled();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Protects the energy usage graph from adding an empty combined Grid
|
||||
* legend item for single-source + battery setups, while still combining
|
||||
* sources when multiple grid imports share a battery-charging period.
|
||||
*/
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import { buildCombinedUsedGrid } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-used-grid";
|
||||
|
||||
const t = 1_700_000_000_000;
|
||||
|
||||
describe("buildCombinedUsedGrid", () => {
|
||||
it("does not add a combined series for a single grid source charging a battery", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsedGrid(
|
||||
fromGridBySource,
|
||||
{ [t]: 3 },
|
||||
{ [t]: 7 }
|
||||
);
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
|
||||
});
|
||||
|
||||
it("combines overlapping grid sources and removes per-source points", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import_a": { [t]: 6 },
|
||||
"sensor.grid_import_b": { [t]: 4 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsedGrid(
|
||||
fromGridBySource,
|
||||
{ [t]: 3 },
|
||||
{ [t]: 7 }
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { [t]: 7 });
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
|
||||
});
|
||||
|
||||
it("does not add a combined series when battery is present but not charging from grid", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsedGrid(fromGridBySource, {}, { [t]: 10 });
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
|
||||
});
|
||||
});
|
||||
@@ -3495,6 +3495,117 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/jsonlint-lines-primitives@npm:^2.0.2, @mapbox/jsonlint-lines-primitives@npm:~2.0.2":
|
||||
version: 2.0.3
|
||||
resolution: "@mapbox/jsonlint-lines-primitives@npm:2.0.3"
|
||||
checksum: 10/99404df11b1840153c910154c238f62ba0ea7cfab6aa7ed3bad1e522d31b746bfad78b23e6ee4887196acec76759f1863b5cb4f5e2d95d0dc2a8ea73091c78f6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/point-geometry@npm:^1.1.0, @mapbox/point-geometry@npm:~1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "@mapbox/point-geometry@npm:1.1.0"
|
||||
checksum: 10/1e649be5c6c83584fae9e043a398c61ad56f81e2ae5334deeb4048884ed1f2a5dc7e727e1757e3159ebf12e6cb1f5b059519078df8d16452bf7c7ab26323eb4a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/tiny-sdf@npm:^2.1.0":
|
||||
version: 2.2.0
|
||||
resolution: "@mapbox/tiny-sdf@npm:2.2.0"
|
||||
checksum: 10/8a6c2664cc6d054155e6812bce41cbb7f2b86d2564adce0aebaadd141a827b78209bc026cd9d4d605095df5556274fb541f520222d19eb2b68d65156db9819d9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/unitbezier@npm:^0.0.1":
|
||||
version: 0.0.1
|
||||
resolution: "@mapbox/unitbezier@npm:0.0.1"
|
||||
checksum: 10/bf104c85dbff37bf47d3217d9457a3abbf23714f78fefadea64e56bdc7c538491b626166809ef28db134f09baccd6ca3df6988a6422df90d8d0c9a23b0686043
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/unitbezier@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@mapbox/unitbezier@npm:1.0.0"
|
||||
checksum: 10/41371f6edf0c643da0867453c18e1f7070d4c342339817495075bbb841d5283589a1b5492da6f2eb617803e0d6efc2e5354080b302b0eb84b13b3dcd64991230
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/vector-tile@npm:^2.0.4":
|
||||
version: 2.0.5
|
||||
resolution: "@mapbox/vector-tile@npm:2.0.5"
|
||||
dependencies:
|
||||
"@mapbox/point-geometry": "npm:~1.1.0"
|
||||
"@types/geojson": "npm:^7946.0.16"
|
||||
pbf: "npm:^4.0.2"
|
||||
checksum: 10/ca788f6c34bc699033f02f6f9a8fc2a4539cfb6c85bdd5a3a143c31698ae8865e99e3b51a4cf11a2070a14c435cba2ec8ca1bcad4eeac0cd678b972a7736c402
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/whoots-js@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "@mapbox/whoots-js@npm:3.1.0"
|
||||
checksum: 10/c1837c04effd205b207f441356d952eae7e8aad6c58f7c4900de50318c2147cf175936fc9434f20dfa409f9e6a78ec604d61e70c1c20572db0cc7655fbb65f50
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@maplibre/geojson-vt@npm:^6.1.0":
|
||||
version: 6.1.1
|
||||
resolution: "@maplibre/geojson-vt@npm:6.1.1"
|
||||
dependencies:
|
||||
kdbush: "npm:^4.1.0"
|
||||
checksum: 10/61c6ca6dc49d799f22dd98b450d48cf172cacf886edbe73e195b0526fe0bad9c42521792f1c7f4361da0069f604517941b71def7f4745fa945c9635a1eb3e250
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@maplibre/maplibre-gl-leaflet@npm:0.1.4":
|
||||
version: 0.1.4
|
||||
resolution: "@maplibre/maplibre-gl-leaflet@npm:0.1.4"
|
||||
peerDependencies:
|
||||
"@types/leaflet": ^1.9.0
|
||||
leaflet: ^1.9.3
|
||||
maplibre-gl: ^2.4.0 || ^3.3.1 || ^4.3.2 || ^5.0.0 || ^6.0.0
|
||||
checksum: 10/9a6d175eaa19d51505aaa2cbd16b78756530cb83a6b770e9a9859df566b9cce38b3ca904423bfde1798e539c6a1e5ff37cc4502e896c829dc219bece8b0b1ee6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@maplibre/maplibre-gl-style-spec@npm:^24.8.1":
|
||||
version: 24.10.0
|
||||
resolution: "@maplibre/maplibre-gl-style-spec@npm:24.10.0"
|
||||
dependencies:
|
||||
"@mapbox/jsonlint-lines-primitives": "npm:~2.0.2"
|
||||
"@mapbox/unitbezier": "npm:^1.0.0"
|
||||
json-stringify-pretty-compact: "npm:^4.0.0"
|
||||
minimist: "npm:^1.2.8"
|
||||
quickselect: "npm:^3.0.0"
|
||||
tinyqueue: "npm:^3.0.0"
|
||||
bin:
|
||||
gl-style-format: dist/gl-style-format.mjs
|
||||
gl-style-migrate: dist/gl-style-migrate.mjs
|
||||
gl-style-validate: dist/gl-style-validate.mjs
|
||||
checksum: 10/7d580c71a7978ffd6c93a95a9178eb55fa0777eb63f1954afec58934e940a7d1432ecd51e98b725e01ae97298b7a60d6a2a19a863952fad45f97ae609ad0d886
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@maplibre/mlt@npm:^1.1.8":
|
||||
version: 1.2.0
|
||||
resolution: "@maplibre/mlt@npm:1.2.0"
|
||||
dependencies:
|
||||
"@mapbox/point-geometry": "npm:^1.1.0"
|
||||
checksum: 10/9bc3d30f78dc2090ac80634e08848bf454daed888fcbcace7e1c1c9b8d1db6c5421376f681dffe90bb4274488753775f36e76a1568ba724e64a0ca59207c04c4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@maplibre/vt-pbf@npm:^4.3.0":
|
||||
version: 4.3.2
|
||||
resolution: "@maplibre/vt-pbf@npm:4.3.2"
|
||||
dependencies:
|
||||
"@mapbox/point-geometry": "npm:^1.1.0"
|
||||
"@types/geojson": "npm:^7946.0.16"
|
||||
pbf: "npm:^5.1.0"
|
||||
checksum: 10/731a6a094964bc004b51451096cdb5ff65ca60f1c443e638f0e316bb1b24431e67988531bb2f9cb854d3ec31597f32fbbf7de550245efac3bfb1a0cc1d33b567
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@marijn/find-cluster-break@npm:^1.0.0":
|
||||
version: 1.0.3
|
||||
resolution: "@marijn/find-cluster-break@npm:1.0.3"
|
||||
@@ -5257,7 +5368,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sveltejs/acorn-typescript@npm:^1.0.10":
|
||||
"@sveltejs/acorn-typescript@npm:^1.0.13":
|
||||
version: 1.0.13
|
||||
resolution: "@sveltejs/acorn-typescript@npm:1.0.13"
|
||||
peerDependencies:
|
||||
@@ -5650,7 +5761,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/geojson@npm:*":
|
||||
"@types/geojson@npm:*, @types/geojson@npm:^7946.0.16":
|
||||
version: 7946.0.16
|
||||
resolution: "@types/geojson@npm:7946.0.16"
|
||||
checksum: 10/34d07421bdd60e7b99fa265441d17ac6e9aef48e3ce22d04324127d0de1daf7fbaa0bd3be1cece2092eb6995f21da84afa5231e24621a2910ff7340bc98f496f
|
||||
@@ -6344,6 +6455,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@versatiles/style@npm:5.13.1":
|
||||
version: 5.13.1
|
||||
resolution: "@versatiles/style@npm:5.13.1"
|
||||
dependencies:
|
||||
brace-expansion: "npm:^5.0.9"
|
||||
checksum: 10/046be455df89f55bc6a8b1b63a01169f68e701799249761c49523c6098b871d9cdb5b0c8a6a1a15503eee17d89f6fb9f0421eb48cf71d70dfa2cee2900a5b25a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vibrant/color@npm:4.0.4, @vibrant/color@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "@vibrant/color@npm:4.0.4"
|
||||
@@ -6667,7 +6787,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.17.0":
|
||||
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.18.0":
|
||||
version: 8.18.0
|
||||
resolution: "acorn@npm:8.18.0"
|
||||
bin:
|
||||
@@ -7312,7 +7432,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"brace-expansion@npm:^5.0.8":
|
||||
"brace-expansion@npm:^5.0.8, brace-expansion@npm:^5.0.9":
|
||||
version: 5.0.9
|
||||
resolution: "brace-expansion@npm:5.0.9"
|
||||
dependencies:
|
||||
@@ -8422,6 +8542,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"earcut@npm:^3.0.2":
|
||||
version: 3.2.3
|
||||
resolution: "earcut@npm:3.2.3"
|
||||
checksum: 10/3c3c6bfc214060e6df83da1da413709cf15c77139b0e094a71e6e960ffce033c88586c1e880496ea0a39d891df74a065ccfe24c18f426898ea07a51e0fc0a446
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eastasianwidth@npm:^0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "eastasianwidth@npm:0.2.0"
|
||||
@@ -9672,6 +9799,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gl-matrix@npm:^3.4.4":
|
||||
version: 3.4.4
|
||||
resolution: "gl-matrix@npm:3.4.4"
|
||||
checksum: 10/0a19a881fbfa2cdcff2b5ece0f62041d17e55665393349653e8742a20e43d4516239c68ac6798baa7c35b0c7bd6c9226e70e7824af162228d471eba358a03090
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2":
|
||||
version: 5.1.2
|
||||
resolution: "glob-parent@npm:5.1.2"
|
||||
@@ -10014,6 +10148,7 @@ __metadata:
|
||||
"@lit/reactive-element": "npm:2.1.2"
|
||||
"@lit/task": "npm:1.0.3"
|
||||
"@lokalise/node-api": "npm:16.3.0"
|
||||
"@maplibre/maplibre-gl-leaflet": "npm: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": "npm:2.5.0"
|
||||
@@ -10046,6 +10181,7 @@ __metadata:
|
||||
"@types/sortablejs": "npm:1.15.9"
|
||||
"@types/tar": "npm:7.0.87"
|
||||
"@typescript/native": "npm:[email protected]"
|
||||
"@versatiles/style": "npm:5.13.1"
|
||||
"@vibrant/color": "npm:4.0.4"
|
||||
"@vitest/coverage-v8": "npm:4.1.11"
|
||||
"@vvo/tzdb": "npm:6.198.0"
|
||||
@@ -10109,9 +10245,10 @@ __metadata:
|
||||
lodash.template: "npm:4.18.1"
|
||||
luxon: "npm:3.7.2"
|
||||
map-stream: "npm:0.0.7"
|
||||
maplibre-gl: "npm:5.24.0"
|
||||
marked: "npm:18.0.10"
|
||||
memoize-one: "npm:6.0.0"
|
||||
minify-literals: "npm:2.1.0"
|
||||
minify-literals: "npm:2.2.0"
|
||||
node-vibrant: "npm:4.0.4"
|
||||
object-hash: "npm:3.0.0"
|
||||
pinst: "npm:3.0.0"
|
||||
@@ -10190,9 +10327,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html-minifier-next@npm:^7.0.0":
|
||||
version: 7.5.3
|
||||
resolution: "html-minifier-next@npm:7.5.3"
|
||||
"html-minifier-next@npm:^8.1.0":
|
||||
version: 8.1.0
|
||||
resolution: "html-minifier-next@npm:8.1.0"
|
||||
dependencies:
|
||||
commander: "npm:^15.0.0"
|
||||
entities: "npm:^8.0.0"
|
||||
@@ -10207,7 +10344,7 @@ __metadata:
|
||||
bin:
|
||||
hmn: cli.js
|
||||
html-minifier-next: cli.js
|
||||
checksum: 10/fe4ad84f5c577c2de936c7aad5c0a7619c5e7ce3763f3c2e15f1ecc9629d89ebc578a38c0e44883cc0388253de262962610f7dbc3101f8d16e363c477a6a9b07
|
||||
checksum: 10/5badf1abd2c6fb9ada2f7dd4929be74f43155510298f7af323b0e39a2c20e2a33ed8de507f105bcea389118f2813e7776b559cf4b2b6397537c5871aa27f2125
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11161,6 +11298,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-stringify-pretty-compact@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "json-stringify-pretty-compact@npm:4.0.0"
|
||||
checksum: 10/a10d5c423e467872994a49c5c1b56b073f277ce02d899cf567fc625f3783b89406bee6408bfb3b4bdeeff509b6a562f5259227e26754a6186f721809ca895f0c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-with-bigint@npm:^3.5.3":
|
||||
version: 3.5.11
|
||||
resolution: "json-with-bigint@npm:3.5.11"
|
||||
@@ -11230,6 +11374,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"kdbush@npm:^4.0.2, kdbush@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "kdbush@npm:4.1.0"
|
||||
checksum: 10/40d0a1a8e9928fabc3b5f7026b280573e718020c74438a4b5702400f8a6e4c38fb6110c456d6e592f56352da7011cc794ed47195c9c501647bcb16287c1d7dd2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"keyv@npm:^4.5.4":
|
||||
version: 4.5.4
|
||||
resolution: "keyv@npm:4.5.4"
|
||||
@@ -11449,7 +11600,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lightningcss@npm:1.33.0, lightningcss@npm:^1.32.0, lightningcss@npm:^1.33.0":
|
||||
"lightningcss@npm:1.33.0, lightningcss@npm:^1.33.0":
|
||||
version: 1.33.0
|
||||
resolution: "lightningcss@npm:1.33.0"
|
||||
dependencies:
|
||||
@@ -11703,6 +11854,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magic-string@npm:^1.2.2":
|
||||
version: 1.2.2
|
||||
resolution: "magic-string@npm:1.2.2"
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec": "npm:^1.5.5"
|
||||
checksum: 10/23514d9fb744d30689037e41440125b6c4bc3fb5b8e6d992798e59f93d7452276f223d062ba9b1ce78fbee5f9cc365c1e0ae3c6babddb74cb3aa9074adb57b03
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magicast@npm:^0.5.2":
|
||||
version: 0.5.4
|
||||
resolution: "magicast@npm:0.5.4"
|
||||
@@ -11757,6 +11917,33 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"maplibre-gl@npm:5.24.0":
|
||||
version: 5.24.0
|
||||
resolution: "maplibre-gl@npm:5.24.0"
|
||||
dependencies:
|
||||
"@mapbox/jsonlint-lines-primitives": "npm:^2.0.2"
|
||||
"@mapbox/point-geometry": "npm:^1.1.0"
|
||||
"@mapbox/tiny-sdf": "npm:^2.1.0"
|
||||
"@mapbox/unitbezier": "npm:^0.0.1"
|
||||
"@mapbox/vector-tile": "npm:^2.0.4"
|
||||
"@mapbox/whoots-js": "npm:^3.1.0"
|
||||
"@maplibre/geojson-vt": "npm:^6.1.0"
|
||||
"@maplibre/maplibre-gl-style-spec": "npm:^24.8.1"
|
||||
"@maplibre/mlt": "npm:^1.1.8"
|
||||
"@maplibre/vt-pbf": "npm:^4.3.0"
|
||||
"@types/geojson": "npm:^7946.0.16"
|
||||
earcut: "npm:^3.0.2"
|
||||
gl-matrix: "npm:^3.4.4"
|
||||
kdbush: "npm:^4.0.2"
|
||||
murmurhash-js: "npm:^1.0.0"
|
||||
pbf: "npm:^4.0.1"
|
||||
potpack: "npm:^2.1.0"
|
||||
quickselect: "npm:^3.0.0"
|
||||
tinyqueue: "npm:^3.0.0"
|
||||
checksum: 10/0bf5bd004e1ae545456407bd42f201913e9e67fb302b7d18804c81dffd0feefad55dfb249514c7ad62e8cea57621777f1061f8187e254a108230e4f7eb4dd8e9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:18.0.10":
|
||||
version: 18.0.10
|
||||
resolution: "marked@npm:18.0.10"
|
||||
@@ -11880,16 +12067,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minify-literals@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "minify-literals@npm:2.1.0"
|
||||
"minify-literals@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "minify-literals@npm:2.2.0"
|
||||
dependencies:
|
||||
"@sveltejs/acorn-typescript": "npm:^1.0.10"
|
||||
acorn: "npm:^8.17.0"
|
||||
html-minifier-next: "npm:^7.0.0"
|
||||
lightningcss: "npm:^1.32.0"
|
||||
magic-string: "npm:^0.30.21"
|
||||
checksum: 10/95bcd25afcf638c00bcd8981c2e6409be8c2feb4fd8f954bc9a547b2ade917810cef17bb075ad95e5ceaac6fffc8e625c161ab77b2363c36de53da6d139de872
|
||||
"@sveltejs/acorn-typescript": "npm:^1.0.13"
|
||||
acorn: "npm:^8.18.0"
|
||||
html-minifier-next: "npm:^8.1.0"
|
||||
lightningcss: "npm:^1.33.0"
|
||||
magic-string: "npm:^1.2.2"
|
||||
checksum: 10/73f17955820675f405b95810f3bf861a05baf426300e8ba46d174987ee45366c885ef1281c40842025529343a4ca71b6fd8294ce674bccbd58685e4a917b9448
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11920,7 +12107,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minimist@npm:^1.2.0":
|
||||
"minimist@npm:^1.2.0, minimist@npm:^1.2.8":
|
||||
version: 1.2.8
|
||||
resolution: "minimist@npm:1.2.8"
|
||||
checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f
|
||||
@@ -12026,6 +12213,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"murmurhash-js@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "murmurhash-js@npm:1.0.0"
|
||||
checksum: 10/875a24e0dd7870e51a7f73906e158fb06de50478669629746a35955cb0a00b6bb797f6b5a2884ee4ec4feefb9c5c27b74190f561eb72530ffc1c5d7c5429f49a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mute-stdout@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "mute-stdout@npm:2.0.0"
|
||||
@@ -12792,6 +12986,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pbf@npm:^4.0.1, pbf@npm:^4.0.2":
|
||||
version: 4.0.2
|
||||
resolution: "pbf@npm:4.0.2"
|
||||
dependencies:
|
||||
resolve-protobuf-schema: "npm:^2.1.0"
|
||||
bin:
|
||||
pbf: bin/pbf
|
||||
checksum: 10/a6d60f2f374c51bcb08b1b49d08c01aed31c4e9cfbf43002178e1cf0f63c5f5fc4aa9dd0220948be775e2d0bcc644954b9d1e8dca91466a5160dcc0a0313edd3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pbf@npm:^5.1.0":
|
||||
version: 5.1.2
|
||||
resolution: "pbf@npm:5.1.2"
|
||||
dependencies:
|
||||
resolve-protobuf-schema: "npm:^2.1.0"
|
||||
bin:
|
||||
pbf: bin/pbf
|
||||
checksum: 10/add0663777f352c9432503e5fa18eb5be112a8ee4d32b233b8c7128bebddb013b874748f63d9db1a6f3050d077c70acdafda75fd8d4b66a75af627ec2db4b7a8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"peek-readable@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "peek-readable@npm:4.1.0"
|
||||
@@ -12925,6 +13141,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"potpack@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "potpack@npm:2.1.0"
|
||||
checksum: 10/8b5c07c8569f06cb14a8034de3057129f8c4909837cb892eb6c1cbe79f06ee68f2e9fbc0610599e2e38a29a489e1ed72fbb4059e7ff6f648826fc961d4c0f607
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"preact@npm:~10.12.1":
|
||||
version: 10.12.1
|
||||
resolution: "preact@npm:10.12.1"
|
||||
@@ -13018,6 +13241,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"protocol-buffers-schema@npm:^3.3.1":
|
||||
version: 3.6.1
|
||||
resolution: "protocol-buffers-schema@npm:3.6.1"
|
||||
checksum: 10/a7ca74e71227932f903feab9df8ffde80703b656fd024179b3a9439edb3907eca4c78f0cd6f47ad7698e832ae565a8df7585d9cedcc4f043794e6ba8e480cdec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"punycode@npm:2.3.1, punycode@npm:^2.1.0, punycode@npm:^2.3.1":
|
||||
version: 2.3.1
|
||||
resolution: "punycode@npm:2.3.1"
|
||||
@@ -13061,6 +13291,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"quickselect@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "quickselect@npm:3.0.0"
|
||||
checksum: 10/8f72bedb8bb14bce5c3767c55f567bc296fa3ca9d98ba385e3867e434463bc633feee1eddf3dfec17914b7e88feeb08c7b313cf47114a8ff11bf964f77f51cfc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"range-parser@npm:1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "range-parser@npm:1.2.0"
|
||||
@@ -13357,6 +13594,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve-protobuf-schema@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "resolve-protobuf-schema@npm:2.1.0"
|
||||
dependencies:
|
||||
protocol-buffers-schema: "npm:^3.3.1"
|
||||
checksum: 10/88fffab2a3757888884a36f9aa4e24be5186b01820a8c26297dc1ce406b9daf776594926bdf524c2c8e8e5b0aba8ac48362b6584cdecc9a7083215ebca01c599
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.11":
|
||||
version: 1.22.12
|
||||
resolution: "resolve@npm:1.22.12"
|
||||
@@ -14730,6 +14976,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyqueue@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "tinyqueue@npm:3.0.0"
|
||||
checksum: 10/44195ae628e98f4de49acefac1fafa63a7f2b5d8a5c23ace6f49917109db3435db8ec9854f87c0d50f8a8c6a73f1526f3941921618a071e4ee1d246afacf69bb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyrainbow@npm:^3.1.0":
|
||||
version: 3.1.1
|
||||
resolution: "tinyrainbow@npm:3.1.1"
|
||||
|
||||
Reference in New Issue
Block a user