Compare commits

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

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

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

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

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

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

* Use the normal create path when remounting itemId pages

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

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

Nested submit/cancel schedules a nextRender callback that ran after
Vitest tore down HTMLElement, failing CI with an unhandled rejection.
2026-08-27 17:06:43 +02:00
Petar PetrovandBram Kragten c43b8bfbe0 Do not show empty Grid legend item on energy usage graph (#53822)
Do not add empty combined Grid series on energy usage graph
2026-08-27 17:06:41 +02:00
Bram KragtenandClaude Opus 5 e349b00b15 Replace CARTO raster tiles with OpenStreetMap vector tiles (#53816)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-27 17:06:40 +02:00
Paul BotteinandBram Kragten 0bb8bdc98a Clarify the replaced device state in pickers and target rows (#53807) 2026-08-27 17:06:39 +02:00
Petar PetrovandBram Kragten bbc5385843 Fix more-info dialog rewriting the URL after navigation (#53806) 2026-08-27 17:06:37 +02:00
Paul BotteinandBram Kragten 43dc5651d3 Keep the automation config when replacing a device in device automations (#53805) 2026-08-27 17:06:36 +02:00
Aidan TimsonandBram Kragten 18dcf7abc0 Refine entity details context section (#53797) 2026-08-27 17:06:35 +02:00
48 changed files with 2538 additions and 365 deletions
+3
View File
@@ -74,3 +74,6 @@ test/e2e/app/dist/
.serena
test/benchmarks/results/
# Downloaded map glyph and sprite archives
.map-assets/
+11 -6
View File
@@ -4,6 +4,7 @@ import fs from "fs-extra";
import gulp from "gulp";
import path from "path";
import paths from "../paths.cjs";
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
const npmPath = (...parts) =>
path.resolve(paths.root_dir, "node_modules", ...parts);
@@ -89,7 +90,7 @@ function copyQrScannerWorker(staticDir) {
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
}
function copyMapPanel(staticDir) {
async function copyMapPanel(staticDir) {
const staticPath = genStaticPath(staticDir);
copyFileDir(
npmPath("leaflet/dist/leaflet.css"),
@@ -103,6 +104,10 @@ function copyMapPanel(staticDir) {
npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/")
);
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
}
function copyZXingWasm(staticDir) {
@@ -139,7 +144,7 @@ gulp.task("copy-static-app", async () => {
copyMdiIcons(staticDir);
// Panel assets
copyMapPanel(staticDir);
await copyMapPanel(staticDir);
// Qr Scanner assets
copyZXingWasm(staticDir);
@@ -155,7 +160,7 @@ gulp.task("copy-static-demo", async () => {
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
copyPolyfills(paths.demo_output_static);
copyMapPanel(paths.demo_output_static);
await copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
copyLocaleData(paths.demo_output_static);
@@ -168,7 +173,7 @@ gulp.task("copy-static-cast", async () => {
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
copyPolyfills(paths.cast_output_static);
copyMapPanel(paths.cast_output_static);
await copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
copyLocaleData(paths.cast_output_static);
@@ -184,7 +189,7 @@ gulp.task("copy-static-gallery", async () => {
paths.gallery_output_root
);
copyMapPanel(paths.gallery_output_static);
await copyMapPanel(paths.gallery_output_static);
copyFonts(paths.gallery_output_static);
copyTranslations(paths.gallery_output_static);
copyLocaleData(paths.gallery_output_static);
@@ -215,7 +220,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
await copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
+1
View File
@@ -14,6 +14,7 @@ import "./gen-icons-json.js";
import "./gen-sensor-entity-constants.js";
import "./landing-page.js";
import "./locale-data.js";
import "./map-assets.js";
import "./rspack.js";
import "./service-worker.js";
import "./translations.js";
+217
View File
@@ -0,0 +1,217 @@
// Assembles the static assets of the vector base map - style, SDF glyphs and
// icon sprites - into /static/map/. vector.openstreetmap.org sets CORS headers
// on its tiles only, so these cannot be loaded from there by a browser.
//
// Glyphs and sprites come from pinned VersaTiles releases, cached locally and
// verified against a digest.
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { colorful, eclipse } from "@versatiles/style";
import fs from "fs-extra";
import gulp from "gulp";
import { extract } from "tar";
import paths from "../paths.cjs";
// The tile URL is deliberately never named here: the OSMF asks consumers to
// resolve it through the TileJSON so they can move the tiles without every
// client needing a release. https://operations.osmfoundation.org/policies/vector/
const TILEJSON_URL =
"https://vector.openstreetmap.org/shortbread_v1/tilejson.json";
const ASSET_PATH = "/static/map";
const ARCHIVES = {
fonts: {
url: "https://github.com/versatiles-org/versatiles-fonts/releases/download/v2.2.0/noto_sans.tar.gz",
sha256: "a2dac39f4096722bc420367ffd4a36687cce7229e8aa760bc12cf657072eea6b",
},
sprites: {
url: "https://github.com/versatiles-org/versatiles-style/releases/download/v5.13.1/sprites.tar.gz",
sha256: "efffd0ee4cb9591bd52f16ff5b269d9618c7dd1db159cd6511943965560ddea5",
},
};
// Rendered with a device font through `localIdeographFontFamily`, so these are
// never downloaded - and they are 90% of the Noto Sans SDF set.
const LOCAL_IDEOGRAPH_BLOCKS = [
[0x2e80, 0x9fff], // CJK radicals through CJK Unified Ideographs, incl. kana
[0xac00, 0xd7ff], // Hangul syllables
[0xf900, 0xfaff], // CJK compatibility ideographs
[0xfe30, 0xfe4f], // CJK compatibility forms
];
// Our styles use bold only for motorway shields, so Latin, Greek and Cyrillic
// cover every ref and the rest of bold - half the glyph set - is dropped.
// `assertBoldStaysOnRefs` guards the assumption.
const BOLD_MAX_CODEPOINT = 0x04ff;
const BOLD_TEXT_FIELD = "{ref}";
const cacheDir = path.resolve(paths.root_dir, ".map-assets");
const outputDir = path.resolve(paths.build_dir, "map");
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
// Downloads an archive into the cache, or reuses it when the digest matches.
const cachedArchive = async (name, { url, sha256: expected }) => {
const file = path.join(cacheDir, `${name}.tar.gz`);
if (await fs.pathExists(file)) {
if (sha256(await readFile(file)) === expected) {
return file;
}
console.warn("Cached map %s archive is stale, downloading again", name);
}
console.log("Downloading map %s from %s", name, url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download map ${name}: ${response.status} ${response.statusText}`
);
}
const body = Buffer.from(await response.arrayBuffer());
const digest = sha256(body);
if (digest !== expected) {
throw new Error(
`Digest mismatch for map ${name}: expected ${expected}, got ${digest}`
);
}
await fs.outputFile(file, body);
return file;
};
const glyphRange = (entryPath) => {
const match = /^(?<font>[^/]+)\/(?<start>\d+)-(?<end>\d+)\.pbf$/.exec(
entryPath
);
return match
? { font: match.groups.font, start: Number(match.groups.start) }
: undefined;
};
const keepGlyph = (entryPath) => {
const range = glyphRange(entryPath);
if (!range) {
return false;
}
if (range.font.endsWith("_bold") && range.start > BOLD_MAX_CODEPOINT) {
return false;
}
return !LOCAL_IDEOGRAPH_BLOCKS.some(
([from, to]) => range.start >= from && range.start <= to
);
};
const keepSprite = (entryPath) =>
/^basics\/sprites(@2x)?\.(json|png)$/.test(entryPath);
// `neutrino`, for one, sets country and state labels in bold - names in any
// script, which would turn to tofu. Fail the build rather than ship that.
const assertBoldStaysOnRefs = (name, style) => {
const offenders = style.layers
.filter((layer) =>
(layer.layout?.["text-font"] ?? []).some((font) => font.endsWith("_bold"))
)
.filter((layer) => layer.layout["text-field"] !== BOLD_TEXT_FIELD)
.map((layer) => layer.id);
if (offenders.length) {
throw new Error(
`Style "${name}" uses bold for ${offenders.join(", ")}, which can hold ` +
`names in any script. Raise BOLD_MAX_CODEPOINT to cover the full set ` +
`before shipping this style.`
);
}
};
// MapLibre extends the fetched TileJSON with the style's source options, so
// anything left here wins and freezes at build time. Dropping them is what lets
// a remote switch move the attribution and zoom range too, not just the URLs.
const TILEJSON_FIELDS = [
"tiles",
"attribution",
"bounds",
"minzoom",
"maxzoom",
"scheme",
];
// The builder can only write a tile URL, so the source is repointed afterwards.
// Keyed on there being exactly one source: any other shape means the builder's
// own default host would ship unnoticed.
const useTileJson = (name, style) => {
const sources = Object.values(style.sources);
if (sources.length !== 1) {
throw new Error(
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
`point at the TileJSON. Check what @versatiles/style emits.`
);
}
for (const field of TILEJSON_FIELDS) {
delete sources[0][field];
}
sources[0].url = TILEJSON_URL;
return style;
};
const styleOptions = {
// Keeps the generated URLs origin relative.
baseUrl: "",
glyphs: `${ASSET_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${ASSET_PATH}/sprites/basics/sprites` }],
};
const buildMapAssets = async () => {
const [fontArchive, spriteArchive] = await Promise.all([
cachedArchive("fonts", ARCHIVES.fonts),
cachedArchive("sprites", ARCHIVES.sprites),
]);
await fs.emptyDir(outputDir);
await Promise.all([
fs.ensureDir(path.join(outputDir, "fonts")),
fs.ensureDir(path.join(outputDir, "sprites")),
]);
await Promise.all([
extract({
file: fontArchive,
cwd: path.join(outputDir, "fonts"),
filter: keepGlyph,
}),
extract({
file: spriteArchive,
cwd: path.join(outputDir, "sprites"),
filter: keepSprite,
}),
// Both themes up front: dark is a real cartography, not an inverted raster.
...[
["light", colorful],
["dark", eclipse],
].map(([name, builder]) => {
const style = useTileJson(name, builder(styleOptions));
assertBoldStaysOnRefs(name, style);
return writeFile(
path.join(outputDir, `${name}.json`),
JSON.stringify(style)
);
}),
]);
};
// Shared so it does not have to be wired into every pipeline separately.
let pending;
export const ensureMapAssets = () => {
pending ??= buildMapAssets();
return pending;
};
gulp.task("build-map-assets", ensureMapAssets);
export const mapAssetsDir = outputDir;
+3
View File
@@ -75,6 +75,7 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.5.0",
@@ -115,6 +116,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
@@ -170,6 +172,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@versatiles/style": "5.13.1",
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.0"
version = "20260826.1"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+1
View File
@@ -27,6 +27,7 @@ const ALLOWED_LICENSES = new Set([
"0BSD",
"CC0-1.0",
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
+30 -21
View File
@@ -1,13 +1,26 @@
import type { Map, TileLayer } from "leaflet";
import type { Map } from "leaflet";
import type { MapBaseLayer } from "../map/base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
export interface LeafletMapSetup {
map: Map;
leaflet: LeafletModuleType;
baseLayer: MapBaseLayer;
}
export const setupLeafletMap = async (
mapElement: HTMLElement,
initialView?: { latitude: number; longitude: number; zoom?: number }
): Promise<[Map, LeafletModuleType, TileLayer]> => {
initialView?: {
latitude: number;
longitude: number;
zoom?: number;
darkMode?: boolean;
}
): Promise<LeafletMapSetup> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
}
@@ -17,7 +30,11 @@ export const setupLeafletMap = async (
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement);
const map = Leaflet.map(mapElement, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet");
@@ -38,21 +55,13 @@ export const setupLeafletMap = async (
);
}
const tileLayer = createTileLayer(Leaflet).addTo(map);
return [map, Leaflet, tileLayer];
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
leaflet.Browser.retina ? "@2x.png" : ".png"
}`,
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: "abcd",
minZoom: 0,
maxZoom: 20,
}
// The base layer adds itself: the vector layer only builds its MapLibre map
// once it is on the map, and that failing has to fall back to raster.
const baseLayer = await createBaseLayer(
Leaflet,
map,
initialView?.darkMode ?? false
);
return { map, leaflet: Leaflet, baseLayer };
};
+230
View File
@@ -0,0 +1,230 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap } from "leaflet";
import type { StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
// endpoint sends CORS headers, so the style, glyphs and sprites are ours to
// serve - see build-scripts/gulp/map-assets.js. The credit comes from the
// TileJSON rather than from here, deliberately: it follows whoever serves the
// tiles.
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
// raster blocks a browser that sends no Referer, and the only referrer a browser
// can send is its origin, which identifies a Nabu Casa installation.
const RASTER_TILE_URL = "https://basemaps.cartocdn.com/rastertiles/voyager";
const CARTO_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
'&copy; <a href="https://carto.com/attributions">CARTO</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, so a dashboard
// full of map cards loses its first ones for good - nothing frees a slot for
// MapLibre to reclaim. A transient loss does get restored, hence the grace.
const CONTEXT_RESTORE_GRACE = 2000;
// On the map, not the layer: only Leaflet tile layers report their own limits,
// and marker clustering throws without a maximum. The floor is 1 because at
// Leaflet zoom 0 the adapter drives MapLibre to -1, outside its range.
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 20;
export interface MapBaseLayer {
// A no-op for raster, which has no dark variant and is inverted in CSS.
setDarkMode: (darkMode: boolean) => void;
}
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets, and blocklisted drivers.
const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
webGL2Supported = Boolean(context);
// Contexts are scarce; the probe must not keep one.
context?.getExtension("WEBGL_lose_context")?.loseContext();
} catch {
webGL2Supported = false;
}
}
return webGL2Supported;
};
// Asset URLs are stored origin relative so they follow the instance's host, but
// MapLibre rejects a relative sprite URL. The glyph URL is left alone: URL
// encoding would mangle its {fontstack} and {range} placeholders.
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (typeof style.sprite === "string") {
style.sprite = new URL(style.sprite, location.href).href;
} else if (Array.isArray(style.sprite)) {
style.sprite = style.sprite.map((sprite) => ({
...sprite,
url: new URL(sprite.url, location.href).href,
}));
}
return style;
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean
): Promise<MapBaseLayer | undefined> => {
let layer: ReturnType<typeof maplibreGL> | undefined;
try {
layer = createLayer({
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
// Draws CJK, kana and hangul with a device font, which is why we ship a
// tenth of the glyph set. No referrer is set: the endpoint serves without
// one, and the only one a browser can send identifies the installation.
localIdeographFontFamily: "sans-serif",
});
// The plugin builds the MapLibre map in `onAdd`, so a refused context, a
// blocked worker or a rejected blob URL throws here. Keep it inside the
// guard or those lose the raster fallback.
layer.addTo(map);
} catch {
if (layer) {
try {
layer.remove();
} catch {
// May never have finished being added.
}
}
return undefined;
}
// Tracked apart because a failed request must roll back to what is displayed,
// not to the opposite of what it asked for - with several in flight those are
// different, and guessing wrong makes the next toggle a permanent no-op.
let appliedDarkMode = darkMode;
let requestedDarkMode = darkMode;
// Styles are fetched, so only the newest request may touch the map.
let latestRequest = 0;
let vector = true;
const glMap = layer.getMaplibreMap();
let fallbackTimeout: number | undefined;
let contextLost = false;
// Declared first, but only ever called once all three exist.
const handleVisibilityChange = () => {
if (contextLost) {
scheduleSwap();
}
};
const swapToRaster = () => {
vector = false;
document.removeEventListener("visibilitychange", handleVisibilityChange);
try {
layer.remove();
} catch {
// Nothing left to detach.
}
createRasterLayer(leaflet, map);
};
const scheduleSwap = () => {
clearTimeout(fallbackTimeout);
// Backgrounding also drops the context, and there it comes back on return.
// Running the clock then would make switching apps enough to lose vector.
if (!vector || document.hidden) {
return;
}
fallbackTimeout = window.setTimeout(swapToRaster, CONTEXT_RESTORE_GRACE);
};
glMap.on("webglcontextlost", () => {
contextLost = true;
scheduleSwap();
});
glMap.on("webglcontextrestored", () => {
contextLost = false;
clearTimeout(fallbackTimeout);
});
document.addEventListener("visibilitychange", handleVisibilityChange);
map.on("unload", () => {
// Otherwise the timer revives a map that is already gone.
clearTimeout(fallbackTimeout);
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
return {
setDarkMode: (newDarkMode: boolean) => {
if (!vector || newDarkMode === requestedDarkMode) {
return;
}
requestedDarkMode = newDarkMode;
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
}
});
},
};
};
const createRasterLayer = (
leaflet: LeafletModuleType,
map: LeafletMap
): MapBaseLayer => {
leaflet
.tileLayer(
// These are the old retina tablets, and CARTO serves @2x - sharp tiles at
// the same request count, where `detectRetina` would fetch a zoom deeper
// at four times as many.
`${RASTER_TILE_URL}/{z}/{x}/{y}${leaflet.Browser.retina ? "@2x" : ""}.png`,
{
attribution: CARTO_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
}
)
.addTo(map);
return { setDarkMode: () => undefined };
};
export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const { maplibreGL: createLayer } =
await import("@maplibre/maplibre-gl-leaflet");
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
map,
darkMode
);
} catch {
// No chunk, no vector map - but still a map.
}
if (vectorLayer) {
return vectorLayer;
}
}
return createRasterLayer(leaflet, map);
};
@@ -12,7 +12,6 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -180,12 +179,15 @@ export abstract class HaDeviceAutomationPicker<
(a, idx) => value === `${a.device_id}_${idx}`
);
const text = automation
const described =
automation ?? (this.value?.domain ? this.value : undefined);
const text = described
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
automation
described
)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
@@ -195,29 +197,24 @@ export abstract class HaDeviceAutomationPicker<
};
private async _updateDeviceInfo() {
// Asking a removed device for its automations fails rather than returning
// an empty list.
this._automations = this.deviceId
? (
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
await this._fetchDeviceAutomations(
this.hass.callWS,
this.deviceId
).catch(() => [] as T[])
).sort(sortDeviceAutomations)
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
// If there is no value, or if we have changed the device ID, reset the value.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
);
}
this._renderEmpty = true;
+71 -47
View File
@@ -1,4 +1,4 @@
import { mdiAlertOutline } from "@mdi/js";
import { mdiSwapHorizontal } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
@@ -22,6 +22,7 @@ import {
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
@@ -104,6 +105,14 @@ export class HaDevicePicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
/**
* The split devices that can actually replace the current value, when the
* caller knows better than this picker. Narrows the replacement candidates,
* so the user is only asked to choose when there is a real choice.
*/
@property({ attribute: false })
public replacementDeviceIds?: string[];
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@@ -187,7 +196,8 @@ export class HaDevicePicker extends LitElement {
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[]
items: (DevicePickerItem | string)[],
replacementDeviceIds: string[] | undefined
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
@@ -203,7 +213,11 @@ export class HaDevicePicker extends LitElement {
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
const candidates = split.split_ids.filter(
(id) =>
selectableIds.has(id) &&
(!replacementDeviceIds || replacementDeviceIds.includes(id))
);
return { candidates, primaryId: split.primary_id };
}
);
@@ -250,26 +264,26 @@ export class HaDevicePicker extends LitElement {
};
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
// The removed device has no name left to show, so say what happened
// to it instead. The alert below names the replacements. Without a
// replacement, fall back to the normal "not found" display.
if (isReplaced) {
return html`
<ha-svg-icon
slot="start"
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
.path=${mdiSwapHorizontal}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
<span slot="headline"
>${this.hass.localize(
"ui.components.device-picker.device_replaced"
)}</span
>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
@@ -399,31 +413,23 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
this._getItems(),
this.replacementDeviceIds
)
: undefined;
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
replacementName
canReplace
);
return html`
<ha-generic-picker
.noUnknownState=${canReplace}
.hass=${this.hass}
.autofocus=${this.autofocus}
.disabled=${this.disabled}
@@ -443,14 +449,9 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -464,30 +465,52 @@ export class HaDevicePicker extends LitElement {
}) {
const { candidates } = replacement;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
// The split devices all inherit the composite's name, so the integration is
// what tells them apart.
const replacementDevice =
candidates.length === 1 ? this.hass.devices[candidates[0]] : undefined;
const replacementName = replacementDevice
? computeDeviceName(replacementDevice)
: undefined;
const replacementDomain = replacementDevice?.primary_config_entry
? this._configEntryLookup[replacementDevice.primary_config_entry]?.domain
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName
replacementName && replacementDomain
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
"ui.components.device-picker.device_replaced_by_one_integration",
{
device: replacementName,
integration: domainToName(
this.hass.localize,
replacementDomain
),
}
)
: replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
variant="warning"
@click=${this._handleReplace}
>
${this.hass.localize("ui.components.device-picker.replace_device")}
${
candidates.length === 1
? this.hass.localize("ui.components.device-picker.replace_update")
: this.hass.localize("ui.components.device-picker.replace_choose")
}
</ha-button>
</ha-alert>
`;
@@ -498,7 +521,8 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
this._getItems(),
this.replacementDeviceIds
);
if (!replacement?.candidates.length) {
return;
+10 -1
View File
@@ -111,6 +111,11 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
// Skip the "unknown value" highlight and note for a value that is not in the
// list but that the value renderer presents on its own.
@property({ type: Boolean, attribute: "no-unknown-state" })
public noUnknownState = false;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -148,7 +153,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("value")) {
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState")
) {
this._setUnknownValue();
}
}
@@ -287,6 +295,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
this.value === null ||
+32 -2
View File
@@ -25,6 +25,7 @@ import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
import type { MapBaseLayer } from "../../common/map/base-layer";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
@@ -160,6 +161,8 @@ export class HaMap extends ReactiveElement {
private Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _resizeObserver?: ResizeObserver;
private _mapItems: (Marker | Circle)[] = [];
@@ -211,6 +214,7 @@ export class HaMap extends ReactiveElement {
this.leafletMap.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
}
// the control went away with the map, so don't hold on to it
@@ -308,6 +312,7 @@ export class HaMap extends ReactiveElement {
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
this._baseLayer?.setDarkMode(this._darkMode);
}
private _loading = false;
@@ -322,11 +327,23 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
const setup = await setupLeafletMap(map, {
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
zoom: this.zoom,
darkMode: this._darkMode,
});
// Setting up fetches a style, so the element can be gone by now.
// `disconnectedCallback` had no map to tear down, and keeping this one
// would leave a live map - and its WebGL context - on a detached host,
// and its container too initialized to set up again on reconnect.
if (!this.isConnected) {
setup.map.remove();
return;
}
this.leafletMap = setup.map;
this.Leaflet = setup.leaflet;
this._baseLayer = setup.baseLayer;
this._updateMapStyle();
this.leafletMap.on("click", (ev) => {
if (this._clickCount === 0) {
@@ -891,9 +908,22 @@ export class HaMap extends ReactiveElement {
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
.leaflet-tile-pane {
/* Only the raster fallback is inverted for dark mode, the vector style
ships its own dark cartography. */
.leaflet-tile-pane .leaflet-tile {
filter: var(--map-filter);
}
/* The only two rules the MapLibre canvas needs from its stylesheet, the
rest of it styles controls and popups we do not render. */
.maplibregl-map {
position: relative;
overflow: hidden;
}
.maplibregl-canvas {
position: absolute;
top: 0;
left: 0;
}
.dark .leaflet-bar a {
background-color: #1c1c1c;
color: #ffffff;
@@ -7,6 +7,7 @@ import {
mdiHome,
mdiLabel,
mdiMinusBox,
mdiSwapHorizontal,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -177,36 +178,51 @@ export class HaTargetPickerItemRow extends LitElement {
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.stateObj=${
stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)
}
>
</ha-state-icon>
`
: nothing
: canMigrate
? html`<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.stateObj=${
stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)
}
>
</ha-state-icon>
`
: nothing
}
</div>
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
<span slot="headline"
>${
canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced_headline"
)
: name
}</span
>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
? replacement!.candidates.length === 1 && replacement!.name
? this.hass.localize(
"ui.components.target-picker.device_replaced_by_one",
{ device: replacement!.name }
)
: this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
@@ -256,7 +272,7 @@ export class HaTargetPickerItemRow extends LitElement {
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_device"
"ui.components.target-picker.replace_update"
)}
</ha-button>
`
+34
View File
@@ -480,6 +480,40 @@ export const migrateAutomationConfig = <
return config;
};
// The fields of the row holding a trigger, condition or action, as opposed to
// the configuration of its type. The UI editors of some types build their value
// from scratch, so these have to be carried over explicitly.
export const TRIGGER_ROW_CONFIG_KEYS = [
"alias",
"note",
"id",
"enabled",
"variables",
] as const;
export const CONDITION_ROW_CONFIG_KEYS = ["alias", "note", "enabled"] as const;
export const ACTION_ROW_CONFIG_KEYS = [
"alias",
"note",
"enabled",
"continue_on_error",
] as const;
export const pickRowConfig = <T extends object>(
row: T,
keys: readonly string[]
): Partial<T> => {
const source = row as Record<string, unknown>;
const config: Record<string, unknown> = {};
for (const key of keys) {
if (key in source) {
config[key] = source[key];
}
}
return config as Partial<T>;
};
export const migrateAutomationTrigger = (
trigger: Trigger | Trigger[],
report?: AutomationMigrationReport
+58 -15
View File
@@ -182,26 +182,69 @@ export const deviceAutomationEditorMode = (
: "unknown-device";
};
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => Object.is(a[property], b[property]));
// An entity can be referenced by its registry id or by its entity id, and the
// two sides do not have to agree.
const deviceAutomationsSameEntity = (
entityRegistry: EntityRegistryEntry[],
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (typeof a !== typeof b) {
if (!a.entity_id && !b.entity_id) {
return true;
}
if (!a.entity_id || !b.entity_id) {
return false;
}
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
return (
a.entity_id === b.entity_id ||
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
);
};
// A device exposes the same automation type once per entity, so the same type
// on another entity is a different automation, not an equivalent one.
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
entityRegistry: EntityRegistryEntry[],
automations: T[],
automation: DeviceAutomation
): T | undefined =>
automations.find(
(candidate) =>
deviceAutomationsSameType(candidate, automation) &&
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
);
// Among the split devices that replaced a removed device, the ones that offer
// the given automation. Nothing in the registry says which of them took it over,
// so each candidate has to be asked.
export const fetchReplacementDevices = async <T extends DeviceAutomation>(
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
automation: DeviceAutomation,
compositeSplits: DeviceCompositeSplits,
fetchDeviceAutomations: (callWS: CallWS, deviceId: string) => Promise<T[]>
): Promise<string[]> => {
const candidates =
compositeSplits[automation.device_id]?.split_ids.filter(
(id) => id in hass.devices
) ?? [];
const automationsPerCandidate = await Promise.all(
candidates.map((id) =>
fetchDeviceAutomations(hass.callWS, id).catch(() => [] as T[])
)
);
return candidates.filter((_id, index) =>
findEquivalentDeviceAutomation(
entityRegistry,
automationsPerCandidate[index],
automation
)
);
};
const compareEntityIdWithEntityRegId = (
+8 -3
View File
@@ -230,13 +230,13 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams) {
if (!this.isConnected || !this._open || this._params !== expectedParams) {
return;
}
await this._waitForSelectorElements();
if (!this._open || this._params !== expectedParams) {
if (!this.isConnected || !this._open || this._params !== expectedParams) {
return;
}
@@ -250,7 +250,12 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams || !this._dialog) {
if (
!this.isConnected ||
!this._open ||
this._params !== expectedParams ||
!this._dialog
) {
return;
}
+208 -54
View File
@@ -1,9 +1,11 @@
import { mdiCheck, mdiContentCopy, mdiDevices, mdiTextureBox } from "@mdi/js";
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
@@ -11,8 +13,16 @@ import { getEntityContext } from "../../common/entity/context/get_entity_context
import checkValidDate from "../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import "../../components/ha-attribute-value";
import "../../components/ha-floor-icon";
import "../../components/ha-icon";
import "../../components/ha-icon-next";
import "../../components/ha-label";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import type { LocalizeKeys } from "../../common/translations/localize";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -25,6 +35,8 @@ import { getFeatures } from "../../common/entity/get_domain_features";
import { supportsFeature } from "../../common/entity/supports-feature";
import { titleCase } from "../../common/string/title-case";
import { stringCompare } from "../../common/string/compare";
import { brandsUrl } from "../../util/brands-url";
import { showToast } from "../../util/toast";
interface DetailsViewParams {
entityId: string;
@@ -33,7 +45,10 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
displayValue?: TemplateResult;
href?: string;
icon?: TemplateResult;
copyable?: boolean;
}
@customElement("ha-more-info-details")
@@ -52,6 +67,17 @@ class HaMoreInfoDetails extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _copiedValue?: string;
private _copyFeedbackTimeout?: number;
public disconnectedCallback(): void {
super.disconnectedCallback();
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = undefined;
this._copiedValue = undefined;
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("entry") && this.entry) {
@@ -93,12 +119,12 @@ class HaMoreInfoDetails extends LitElement {
? this.hass.localize(`component.${this.entry.platform}.title`) ||
this.entry.platform
: undefined;
const labelNames =
this.entry?.labels.map(
(labelId) =>
this._labels?.find((label) => label.label_id === labelId)?.name ??
labelId
) ?? [];
const labels =
this.entry?.labels.map((labelId) => ({
id: labelId,
entry: this._labels?.find((label) => label.label_id === labelId),
})) ?? [];
const labelNames = labels.map(({ id, entry }) => entry?.name ?? id);
const contextEntries: DetailEntry[] = [];
if (floor && floorName) {
@@ -106,6 +132,7 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.dialogs.more_info_control.floor",
value: floorName,
href: "/config/areas/dashboard",
icon: html`<ha-floor-icon slot="end" .floor=${floor}></ha-floor-icon>`,
});
}
if (area && areaName) {
@@ -113,6 +140,9 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.area",
value: areaName,
href: `/config/areas/area/${area.area_id}`,
icon: area.icon
? html`<ha-icon slot="end" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
});
}
if (device && deviceName) {
@@ -120,6 +150,7 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.device",
value: deviceName,
href: `/config/devices/device/${device.id}`,
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
});
}
if (this.entry?.platform && integrationName) {
@@ -129,31 +160,63 @@ class HaMoreInfoDetails extends LitElement {
href: this.entry.config_entry_id
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
: undefined,
icon: html`<img
slot="end"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: this.entry.platform,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`,
});
}
const entityEntries: DetailEntry[] = [
contextEntries.push(
{
translationKey: "ui.dialogs.more_info_control.entity_id",
value: this.params.entityId,
copyable: true,
},
{
translationKey: "ui.dialogs.more_info_control.labels",
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
},
];
displayValue: labels.length
? html`<div class="labels">
${labels.map(
({ id, entry }) => html`
<ha-label
class="text-ellipsis"
.color=${entry?.color ?? undefined}
.description=${entry?.description ?? undefined}
>
${
entry?.icon
? html`<ha-icon
slot="icon"
.icon=${entry.icon}
></ha-icon>`
: nothing
}
${entry?.name ?? id}
</ha-label>
`
)}
</div>`
: undefined,
}
);
const yamlData = {
...(contextEntries.length
? {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
},
}
: {}),
entity: {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
entity_id: this.params.entityId,
labels: labelNames,
},
@@ -171,17 +234,9 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
${
contextEntries.length
? html`<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>`
: nothing
}
<ha-grouped-list>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
@@ -191,14 +246,6 @@ class HaMoreInfoDetails extends LitElement {
${this._renderEntries(stateEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.entity"
)}
>
${this._renderEntries(entityEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
@@ -284,17 +331,72 @@ class HaMoreInfoDetails extends LitElement {
}
private _renderEntries(entries: DetailEntry[]) {
return entries.map(
(entry) => html`
<ha-list-item-value .label=${this.hass.localize(entry.translationKey)}>
${
entry.href
? html`<a href=${entry.href}>${entry.value}</a>`
: entry.value
}
</ha-list-item-value>
`
);
return entries.map((entry) => {
const label = this.hass.localize(entry.translationKey);
if (!entry.href && !entry.copyable) {
return html`
<ha-list-item-value .label=${label}
>${entry.displayValue ?? entry.value}</ha-list-item-value
>
`;
}
if (entry.copyable) {
return html`
<ha-list-item-button
aria-label=${this.hass.localize(
"ui.dialogs.more_info_control.copy_value",
{ label, value: entry.value }
)}
data-value=${entry.value}
@click=${this._copyValue}
>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
<ha-svg-icon
class=${this._copiedValue === entry.value ? "copy-success" : ""}
slot="end"
.path=${
this._copiedValue === entry.value ? mdiCheck : mdiContentCopy
}
></ha-svg-icon>
</ha-list-item-button>
`;
}
return html`
<ha-list-item-button .href=${entry.href}>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
${entry.icon ?? nothing}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
});
}
private async _copyValue(ev: HASSDomCurrentTargetEvent<HaListItemButton>) {
const value = ev.currentTarget.dataset.value;
if (value === undefined) {
return;
}
await copyToClipboard(value);
const duration = 4000;
this._copiedValue = value;
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = window.setTimeout(() => {
this._copiedValue = undefined;
this._copyFeedbackTimeout = undefined;
}, duration);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
duration,
});
}
private _renderAttributes(attributes: { name: string; label: string }[]) {
@@ -358,11 +460,63 @@ class HaMoreInfoDetails extends LitElement {
}
ha-grouped-list + ha-grouped-list {
margin-top: var(--ha-space-4);
margin-top: var(--ha-space-6);
}
a {
color: var(--primary-color);
ha-list-item-button {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
--ha-row-item-gap: var(--ha-space-3);
--mdc-icon-size: 20px;
}
ha-list-item-button::part(end) {
gap: var(--ha-space-2);
}
ha-list-item-button img {
width: 20px;
height: 20px;
object-fit: contain;
}
.link-row {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--ha-space-3);
}
.link-row .label {
flex: 1;
color: var(--secondary-text-color);
}
.link-row .value {
max-width: 60%;
min-width: 0;
text-align: end;
overflow-wrap: anywhere;
}
.labels {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--ha-space-1);
}
.labels ha-label {
min-width: 0;
max-width: 100%;
}
ha-icon-next {
color: var(--secondary-text-color);
}
ha-svg-icon.copy-success {
color: var(--success-color);
}
.empty {
+12 -2
View File
@@ -28,6 +28,7 @@ import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { mainWindow } from "../../common/dom/get_main_window";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
@@ -47,7 +48,10 @@ import {
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
import {
createMoreInfoUrl,
decodeMoreInfoUrl,
} from "../../common/url/more-info-query-params";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -231,7 +235,13 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
if (this._returnUrl) {
// Restore the pre-dialog URL only while the URL still carries this
// dialog's deep-link params: navigate() waits for the close only up to
// DIALOG_WAIT_TIMEOUT and may have committed a new URL already.
if (
this._returnUrl &&
decodeMoreInfoUrl(mainWindow.location.search).entityId === this._entityId
) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
+22 -4
View File
@@ -24,6 +24,8 @@ export interface RouteOptions {
// Function to load the page.
load?: () => Promise<unknown>;
cache?: boolean;
// Recreate the page when the remaining path (the item id) changes.
itemId?: boolean;
waitForReady?: boolean;
}
@@ -138,10 +140,23 @@ export class HassRouterPage extends ReactiveElement {
}
if (this._currentPage === newPage) {
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
const oldRoute = changedProps.get("route");
const oldTail = oldRoute ? computeRouteTail(oldRoute).path : undefined;
const newTail = route ? this._computeTail(route).path : undefined;
if (
typeof routeOptions === "object" &&
routeOptions.itemId &&
oldTail !== newTail
) {
// Fall through to the normal create path so `load` / loading screen
// still run. itemId pages are not cached, so this is a new element.
this._currentPage = "";
} else {
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
}
return;
}
if (!routeOptions) {
@@ -365,7 +380,10 @@ export class HassRouterPage extends ReactiveElement {
this.updatePageEl(panelEl);
this.appendChild(panelEl);
if (routerOptions.cacheAll || routeOptions.cache) {
if (
(routerOptions.cacheAll || routeOptions.cache) &&
!routeOptions.itemId
) {
this._cache[page] = panelEl;
}
}
@@ -3,6 +3,10 @@ import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
ACTION_ROW_CONFIG_KEYS,
pickRowConfig,
} from "../../../../data/automation";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
@@ -107,9 +111,8 @@ export default class HaAutomationActionEditor extends LitElement {
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.action.alias ? { alias: this.action.alias } : {}),
...(this.action.note ? { note: this.action.note } : {}),
...ev.detail.value,
...pickRowConfig(this.action, ACTION_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -14,6 +14,8 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceActions,
deviceAutomationsEqual,
fetchDeviceActionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -40,6 +42,8 @@ export class HaDeviceAction extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -95,13 +99,27 @@ export class HaDeviceAction extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.action,
compositeSplits,
fetchDeviceActions
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -115,6 +133,7 @@ export class HaDeviceAction extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.disabled=${this.disabled}
@value-changed=${this._devicePicked}
.hass=${this.hass}
@@ -156,6 +175,19 @@ export class HaDeviceAction extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("action");
if (previous && previous.device_id !== this.action.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -185,6 +217,15 @@ export class HaDeviceAction extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.action, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -7,7 +7,11 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { Condition } from "../../../../data/automation";
import { expandConditionWithShorthand } from "../../../../data/automation";
import {
CONDITION_ROW_CONFIG_KEYS,
expandConditionWithShorthand,
pickRowConfig,
} from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
@@ -125,9 +129,8 @@ export default class HaAutomationConditionEditor extends LitElement {
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.condition.alias ? { alias: this.condition.alias } : {}),
...(this.condition.note ? { note: this.condition.note } : {}),
...ev.detail.value,
...pickRowConfig(this.condition, CONDITION_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -14,6 +14,8 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceConditions,
deviceAutomationsEqual,
fetchDeviceConditionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -40,6 +42,8 @@ export class HaDeviceCondition extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -96,13 +100,27 @@ export class HaDeviceCondition extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.condition,
compositeSplits,
fetchDeviceConditions
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -116,6 +134,7 @@ export class HaDeviceCondition extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -157,6 +176,19 @@ export class HaDeviceCondition extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("condition");
if (previous && previous.device_id !== this.condition.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -187,6 +219,15 @@ export class HaDeviceCondition extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.condition, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -278,6 +278,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
const domain = hooks.domain;
try {
const config = await hooks.fetchFileConfig(this.hass, id);
if (!this.isConnected) {
return;
}
this.readOnly = false;
const report: AutomationMigrationReport = { deprecated: false };
this.config = hooks.normalizeConfig(config, report);
@@ -294,6 +297,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
);
hooks.checkValidation();
} catch (err: any) {
if (!this.isConnected) {
return;
}
if (err.status_code !== 404) {
const alertText =
err.body?.message || err.body || err.error || "Unknown error";
@@ -47,9 +47,11 @@ class HaConfigAutomation extends HassRouterPage {
},
edit: {
tag: "ha-automation-editor",
itemId: true,
},
show: {
tag: "ha-automation-editor",
itemId: true,
},
trace: {
tag: "ha-automation-trace",
@@ -7,6 +7,7 @@ import {
mdiFormatListBulleted,
mdiMenuDown,
mdiShape,
mdiSwapHorizontal,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import {
@@ -321,14 +322,25 @@ export class HaAutomationRowTargets extends LitElement {
let lastTargetType: string | null = null;
// The collapsed summary hides the individual targets, so carry over the
// warning when any of them no longer exists.
const hasMissingTarget = rows.some(
([targetType, targetId]) => !this._checkTargetExists(targetType, targetId)
);
return html`
<ha-dropdown
@wa-select=${this._handleTargetSelect}
@click=${stopPropagation}
@keydown=${stopPropagation}
>
<button slot="trigger" class="target">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<button
slot="trigger"
class=${classMap({ target: true, warning: hasMissingTarget })}
>
<ha-svg-icon
.path=${hasMissingTarget ? mdiAlert : mdiFormatListBulleted}
></ha-svg-icon>
<div class="label">
${this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.targets",
@@ -467,9 +479,15 @@ export class HaAutomationRowTargets extends LitElement {
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
if (targetType === "device" && this._compositeSplits?.[targetId]) {
if (
targetType === "device" &&
this._compositeSplits?.[targetId]?.split_ids.some(
(id) => id in this._registries.devices
)
) {
// The device was replaced by one or more split devices; make clear
// this reference needs to be updated, distinct from "unknown device".
icon = mdiSwapHorizontal;
label = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced"
);
@@ -686,6 +704,9 @@ export class HaAutomationRowTargets extends LitElement {
background-color: var(--ha-color-fill-warning-quiet-resting);
color: var(--ha-color-on-warning-normal);
}
ha-dropdown-item.warning ha-svg-icon {
color: var(--ha-color-on-warning-normal);
}
ha-dropdown-item.warning:hover {
background-color: var(--ha-color-fill-warning-quiet-hover);
color: var(--ha-color-on-warning-normal);
@@ -8,7 +8,11 @@ import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import "../../../../components/input/ha-input";
import type { Trigger } from "../../../../data/automation";
import { migrateAutomationTrigger } from "../../../../data/automation";
import {
TRIGGER_ROW_CONFIG_KEYS,
migrateAutomationTrigger,
pickRowConfig,
} from "../../../../data/automation";
import type { TriggerDescription } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
@@ -144,9 +148,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
if (isTriggerList(this.trigger)) return;
ev.stopPropagation();
const value = {
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
...(this.trigger.note ? { note: this.trigger.note } : {}),
...ev.detail.value,
...pickRowConfig(this.trigger, TRIGGER_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -16,6 +16,8 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceTriggers,
deviceAutomationsEqual,
fetchDeviceTriggerCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -42,6 +44,8 @@ export class HaDeviceTrigger extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -100,13 +104,27 @@ export class HaDeviceTrigger extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.trigger,
compositeSplits,
fetchDeviceTriggers
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -120,6 +138,7 @@ export class HaDeviceTrigger extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -161,6 +180,19 @@ export class HaDeviceTrigger extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("trigger");
if (previous && previous.device_id !== this.trigger.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -208,6 +240,15 @@ export class HaDeviceTrigger extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.trigger, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -225,9 +266,6 @@ export class HaDeviceTrigger extends LitElement {
) {
trigger = this._origTrigger;
}
if (this.trigger.id) {
trigger.id = this.trigger.id;
}
fireEvent(this, "value-changed", { value: trigger });
}
@@ -9,6 +9,7 @@ import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformTrigger } from "../../../../../data/automation";
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
@@ -27,15 +28,11 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
!("boolean" in field.selector && field.default);
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
...TRIGGER_ROW_CONFIG_KEYS,
"trigger",
"target",
"alias",
"note",
"id",
"variables",
"enabled",
"options",
] as const;
];
@customElement("ha-automation-trigger-platform")
export class HaPlatformTrigger extends LitElement {
@@ -256,10 +256,14 @@ class MqttSubscribeCard extends LitElement {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--ha-space-2);
}
ha-button {
align-self: flex-start;
margin-top: var(--ha-space-3);
}
ha-select {
width: 96px;
margin: 0 8px;
}
ha-input {
flex: 1;
@@ -281,10 +285,6 @@ class MqttSubscribeCard extends LitElement {
@media screen and (max-width: 600px) {
ha-select {
display: block;
margin-left: 0px;
margin-top: 8px;
margin-inline-start: 0px;
margin-inline-end: initial;
}
ha-input {
flex: auto;
@@ -1,11 +1,13 @@
import { mdiContentCopy } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-adaptive-dialog";
import "../../../../../components/ha-icon-button";
import "../../../../../components/item/ha-list-item-value";
import "../../../../../components/list/ha-grouped-list";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
@@ -85,64 +87,34 @@ class DialogSerialPortInfo extends LitElement {
}
return html`
<ha-dialog
<ha-adaptive-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.serial.port_information"
)}
@closed=${this._dialogClosed}
>
<table>
<tbody>
${this._fields().map(
([label, value]) => html`
<tr>
<th>${label}</th>
<td>${value}</td>
</tr>
`
)}
</tbody>
</table>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._copyToClipboard}
>
${this.hass.localize("ui.common.copy")}
</ha-button>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize("ui.common.copy")}
.path=${mdiContentCopy}
@click=${this._copyToClipboard}
></ha-icon-button>
<ha-grouped-list>
${this._fields().map(
([label, value]) =>
html`<ha-list-item-value .label=${label}
>${value}</ha-list-item-value
>`
)}
</ha-grouped-list>
</ha-adaptive-dialog>
`;
}
static readonly styles: CSSResultGroup = css`
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: start;
vertical-align: top;
white-space: nowrap;
padding-inline-end: var(--ha-space-4);
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
td {
width: 100%;
word-break: break-all;
}
tr:not(:first-child) th,
tr:not(:first-child) td {
padding-top: var(--ha-space-2);
ha-grouped-list {
--ha-list-item-value-max-width: 80%;
}
`;
}
@@ -192,6 +192,16 @@ export class SerialConfigDashboard extends LitElement {
});
}
private _renderConsumerIcon(src: string, alt: string): TemplateResult {
return html`<img
slot="start"
.src=${src}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${alt}
/>`;
}
private _renderConsumer(consumer: SerialPortConsumer): TemplateResult {
const href =
consumer.kind === "config_entry"
@@ -202,21 +212,26 @@ export class SerialConfigDashboard extends LitElement {
<ha-md-list-item type="link" href=${href} class="consumer">
${
consumer.kind === "config_entry"
? html`<img
slot="start"
.src=${brandsUrl(
? this._renderConsumerIcon(
brandsUrl(
{
domain: consumer.domain!,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${consumer.domain!}
/>`
: html`<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>`
),
consumer.domain!
)
: consumer.kind === "app"
? this._renderConsumerIcon(
`/api/hassio/addons/${consumer.slug}/icon`,
consumer.slug!
)
: html`<ha-svg-icon
slot="start"
.path=${mdiPuzzle}
></ha-svg-icon>`
}
<div slot="headline">${this._consumerName(consumer)}</div>
<ha-icon-next slot="end"></ha-icon-next>
@@ -40,6 +40,7 @@ class HaConfigScene extends HassRouterPage {
},
edit: {
tag: "ha-scene-editor",
itemId: true,
},
},
};
+16 -5
View File
@@ -911,11 +911,15 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _subscribeEvents() {
this._unsubscribeEvents =
await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
if (!this.isConnected || this._mode !== "live") {
unsubscribe();
return;
}
this._unsubscribeEvents = unsubscribe;
}
private _showMoreInfo(ev: Event) {
@@ -928,6 +932,9 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
try {
config = await getSceneConfig(this.hass, this.sceneId!);
} catch (err: any) {
if (!this.isConnected) {
return;
}
await showAlertDialog(this, {
text:
err.status_code === 404
@@ -943,6 +950,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return;
}
if (!this.isConnected) {
return;
}
if (!config.entities) {
config.entities = {};
}
@@ -40,9 +40,11 @@ class HaConfigScript extends HassRouterPage {
},
edit: {
tag: "ha-script-editor",
itemId: true,
},
show: {
tag: "ha-script-editor",
itemId: true,
},
trace: {
tag: "ha-script-trace",
@@ -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;
+19 -16
View File
@@ -802,8 +802,10 @@
"device_not_found": "Device not found",
"entity_not_found": "Entity not found",
"label_not_found": "Label not found",
"device_replaced_headline": "Replaced device",
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"replace_device": "Replace",
"device_replaced_by_one": "Replaced by {device}",
"replace_update": "Update",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"entities_count_filtered": "{count}/{total} {total, plural,\n one {entity}\n other {entities}\n}",
@@ -924,10 +926,12 @@
"no_area": "No area",
"placeholder": "Select a device",
"unknown": "Unknown device selected",
"device_replaced_count": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"device_replaced_by_one": "This device was replaced by {device}.",
"device_replaced_by_multiple": "This device was replaced by {count} devices.",
"replace_device": "Replace",
"device_replaced": "Replaced device",
"device_replaced_by_one": "This device no longer exists. It was replaced by {device}.",
"device_replaced_by_one_integration": "This device no longer exists. It was replaced by {device} from {integration}.",
"device_replaced_by_multiple": "This device no longer exists. It was replaced by {count} devices, pick the one to use here.",
"replace_update": "Update",
"replace_choose": "Choose",
"replaced_dialog": {
"title": "Replace device",
"description": "This device was replaced by multiple devices. Choose which one to use.",
@@ -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",
+349
View File
@@ -0,0 +1,349 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LeafletModuleType } from "../../../src/common/dom/setup-leaflet-map";
// The fallback to raster tiles is what keeps the map working on devices
// without WebGL2, on instances that cannot load the MapLibre chunk, and when
// building the MapLibre map itself fails - a blocked worker, an exhausted
// WebGL context budget. None of that is reachable from the `ha-map` tests,
// which run in jsdom and therefore only ever take the raster branch.
const maplibreLayer = vi.hoisted(() => ({
addTo: vi.fn(),
remove: vi.fn(),
options: {} as { attribution?: string },
getMaplibreMap: vi.fn(),
}));
const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
const STYLE = {
version: 8,
sources: {},
layers: [],
sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }],
};
const rasterLayer = { addTo: vi.fn() };
// Kept as a local so tests can flip it: @types/leaflet has it readonly.
const browser = { retina: false };
const leaflet = {
tileLayer: vi.fn(() => rasterLayer),
Browser: browser,
} as unknown as LeafletModuleType;
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
const glHandlers: Record<string, () => void> = {};
const glMap = {
setStyle: vi.fn(),
on: vi.fn((event: string, handler: () => void) => {
glHandlers[event] = handler;
}),
};
const map = { on: vi.fn() } as any;
// The WebGL2 probe is cached for the lifetime of the module, so every test
// needs its own copy of it.
const setWebGL2 = async (supported: boolean) => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() =>
supported ? ({ getExtension: () => null } as any) : null
);
return (await import("../../../src/common/map/base-layer")).createBaseLayer;
};
const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
maplibreLayer.options = {};
maplibreGL.mockReturnValue(maplibreLayer);
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
maplibreLayer.getMaplibreMap.mockReturnValue(glMap);
for (const event of Object.keys(glHandlers)) {
delete glHandlers[event];
}
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("createBaseLayer", () => {
it("falls back to raster tiles without WebGL2", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
// No referrer: the only one a browser can send is its origin, which
// identifies a Nabu Casa installation. The fallback source is chosen so it
// does not need one.
expect(options).not.toHaveProperty("referrerPolicy");
// The vector layer takes its credit from the style's source instead, so the
// raster layer is the only one carrying attribution itself - and it credits
// both the data and whoever rendered it.
expect(options.attribution).toContain("openstreetmap.org/copyright");
expect(options.attribution).toContain("carto.com/attributions");
expect(url).toMatch(/\{z\}\/\{x\}\/\{y\}/);
});
// The devices on the fallback are the old retina tablets, and the source
// serves @2x, so they get sharp tiles without quadrupling the requests.
it("asks for @2x raster tiles on a retina screen", async () => {
const createBaseLayer = await setWebGL2(false);
browser.retina = true;
await createBaseLayer(leaflet, map, false);
expect(vi.mocked(leaflet.tileLayer).mock.calls[0][0]).toContain("@2x.png");
browser.retina = false;
});
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
expect(maplibreGL).toHaveBeenCalledOnce();
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
it("falls back to raster tiles when the style cannot be fetched", async () => {
const createBaseLayer = await setWebGL2(true);
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("offline");
})
);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
// The plugin only builds the MapLibre map once the layer is added, so this
// is where a blocked worker or a refused WebGL context surfaces.
it("falls back to raster tiles when adding the vector layer throws", async () => {
const createBaseLayer = await setWebGL2(true);
maplibreLayer.addTo.mockImplementation(() => {
throw new Error("Failed to initialize WebGL");
});
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreLayer.remove).toHaveBeenCalled();
});
it("still falls back when tearing down the half-added layer throws", async () => {
const createBaseLayer = await setWebGL2(true);
maplibreLayer.addTo.mockImplementation(() => {
throw new Error("Failed to initialize WebGL");
});
maplibreLayer.remove.mockImplementation(() => {
throw new Error("nothing to remove");
});
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
});
describe("setDarkMode", () => {
beforeEach(() => {
glMap.setStyle.mockClear();
});
it("swaps the style, and ignores a repeat of the current mode", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
baseLayer.setDarkMode(true);
expect(glMap.setStyle).toHaveBeenCalledOnce();
});
// A failed request must roll back to the style that is actually on screen.
// Rolling back to the opposite of the failed request instead would desync
// the tracked mode, and the next toggle to that mode would do nothing.
it("can retry a mode whose request failed while another was in flight", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false);
const failing = vi.fn(async () => {
throw new Error("offline");
});
vi.stubGlobal("fetch", failing);
// Dark is superseded by light, which then fails: the map is still light.
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
await vi.waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
// Both rejections have to land before the retry, or this passes whatever
// the rollback does.
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
});
// Styles are fetched, so a burst of theme changes can resolve out of order.
// Applying a stale one would leave the map on the wrong theme for good.
it("ignores a style that resolves after a newer request", async () => {
const createBaseLayer = await setWebGL2(true);
const resolvers: ((value: unknown) => void)[] = [];
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise((resolve) => {
resolvers.push(resolve);
})
)
);
const styleResponse = (name: string) => ({
json: async () => ({ ...structuredClone(STYLE), name }),
});
// The layer only settles once its first style resolves, so let that one
// through before the map exists to switch.
const pending = createBaseLayer(leaflet, map, false);
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
resolvers.shift()!(styleResponse("light"));
const baseLayer = await pending;
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
await vi.waitFor(() => expect(resolvers).toHaveLength(2));
// The dark request, which is no longer the newest, comes back last.
resolvers[1](styleResponse("light"));
resolvers[0](styleResponse("dark"));
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
expect(glMap.setStyle.mock.calls[0][0]).toMatchObject({ name: "light" });
});
});
// Browsers cap the number of live WebGL contexts and drop the oldest. With
// more map cards than that cap - measured at 16 in Chrome - the first cards
// lose their context and never get it back, because nothing frees a slot.
describe("WebGL context loss", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("falls back to raster tiles when the context stays lost", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(maplibreLayer.remove).toHaveBeenCalled();
expect(isRaster()).toBe(true);
});
it("keeps the vector layer when the context comes back", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
vi.runAllTimers();
expect(maplibreLayer.remove).not.toHaveBeenCalled();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
// Backgrounding a tab loses the context too, and there it comes back when
// the page is shown again. Running the clock anyway would mean switching
// apps for a few seconds was enough to come back to a raster map.
it("waits for the page to be visible before falling back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(isRaster()).toBe(true);
});
it("keeps the vector layer when a hidden page gets its context back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
it("stops listening for visibility once it has fallen back", async () => {
const remove = vi.spyOn(document, "removeEventListener");
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(remove).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function)
);
// And a later visibility change must not add a second raster layer.
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(isRaster()).toBe(true);
});
it("stops answering theme changes once it has fallen back", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
baseLayer.setDarkMode(true);
expect(glMap.setStyle).not.toHaveBeenCalled();
});
});
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import type { DeviceTrigger } from "../../src/data/device/device_automation";
import {
fetchReplacementDevices,
findEquivalentDeviceAutomation,
} from "../../src/data/device/device_automation";
import type { DeviceCompositeSplits } from "../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../src/data/entity/entity_registry";
import type { HomeAssistant } from "../../src/types";
const entityRegistry = [
{ id: "regid1", entity_id: "binary_sensor.one" },
{ id: "regid2", entity_id: "binary_sensor.two" },
] as EntityRegistryEntry[];
const trigger = (partial: Partial<DeviceTrigger>): DeviceTrigger =>
({
trigger: "device",
domain: "binary_sensor",
device_id: "device1",
...partial,
}) as DeviceTrigger;
describe("findEquivalentDeviceAutomation", () => {
it("picks the automation on the same entity among several of the same type", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid2" })
)
).toBe(automations[1]);
});
it("matches an entity referenced by entity id against one referenced by registry id", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "binary_sensor.two" })
)
).toBe(automations[1]);
});
it("returns undefined when the same type is only offered for another entity", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({
device_id: "device2",
type: "turned_off",
entity_id: "regid1",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid2" })
)
).toBeUndefined();
});
it("matches entity-less automations on their subtype", () => {
const automations = [
trigger({
device_id: "device2",
domain: "zha",
type: "remote_button_short_press",
subtype: "button_1",
}),
trigger({
device_id: "device2",
domain: "zha",
type: "remote_button_short_press",
subtype: "button_2",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({
domain: "zha",
type: "remote_button_short_press",
subtype: "button_2",
})
)
).toBe(automations[1]);
});
it("returns undefined when the device offers no automation of that type", () => {
const automations = [
trigger({
device_id: "device2",
type: "turned_off",
entity_id: "regid1",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid1" })
)
).toBeUndefined();
});
});
describe("fetchReplacementDevices", () => {
const hass = {
callWS: () => Promise.resolve([]),
devices: { device2: {}, device3: {} },
} as unknown as HomeAssistant;
const compositeSplits = {
removed: { split_ids: ["device2", "device3"], primary_id: "device2" },
} as unknown as DeviceCompositeSplits;
const value = trigger({
device_id: "removed",
type: "turned_on",
entity_id: "regid1",
});
it("keeps only the devices that offer the automation", async () => {
const offers = {
device2: [
trigger({
device_id: "device2",
type: "turned_on",
entity_id: "regid1",
}),
],
device3: [
trigger({
device_id: "device3",
type: "turned_on",
entity_id: "regid2",
}),
],
};
expect(
await fetchReplacementDevices(
hass,
entityRegistry,
value,
compositeSplits,
(_callWS, deviceId) => Promise.resolve(offers[deviceId])
)
).toEqual(["device2"]);
});
it("drops a device whose automations cannot be listed", async () => {
expect(
await fetchReplacementDevices(
hass,
entityRegistry,
value,
compositeSplits,
(_callWS, deviceId) =>
deviceId === "device2"
? Promise.reject(new Error("unknown device"))
: Promise.resolve([
trigger({
device_id: "device3",
type: "turned_on",
entity_id: "regid1",
}),
])
)
).toEqual(["device3"]);
});
});
+7 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deepActiveElement } from "../../../src/common/dom/deep-active-element";
import { nextRender } from "../../../src/common/util/render-status";
import type {
FormDialogData,
FormDialogParams,
@@ -146,8 +147,13 @@ const submit = (dialog: DialogForm) =>
const cancel = (dialog: DialogForm) =>
(getInternals(dialog)["_cancel"] as () => void)();
afterEach(() => {
afterEach(async () => {
mockForm.delayedTag = undefined;
document.body.querySelectorAll("dialog-form").forEach((el) => {
(el as DialogForm).closeDialog();
});
// Drain the fire-and-forget focus restore before jsdom teardown.
await nextRender();
document.body.replaceChildren();
vi.clearAllMocks();
});
+89 -24
View File
@@ -14,6 +14,7 @@ import {
defineRouteSmokeTests,
ensureAppSidebarPanelVisible,
goToPanel,
openMoreInfoDialog,
} from "./app/src/helpers";
import {
expectNoPageErrors,
@@ -370,34 +371,16 @@ test.describe("Light more-info dialog", () => {
// The light-more-info scenario seeds light.test_light synchronously.
await goToPanel(page, "/?scenario=light-more-info#/lovelace");
const dialog = page.locator("ha-more-info-dialog");
// Fire the standard hass-more-info event from the app root with an
// explicit view. The HA shell opens ha-more-info-dialog on the requested
// view directly, so the test does not depend on the admin/demo-hidden
// header controls.
//
// The event is one-shot: if it lands before the shell's hass-more-info
// listener is attached it is silently dropped. Re-dispatching is
// idempotent (showDialog just resets the dialog to the requested view),
// so poll the dispatch until the requested view actually renders.
await expect(async () => {
await page.evaluate((v) => {
const el = document.querySelector("ha-test");
el?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: { entityId: "light.test_light", view: v },
bubbles: true,
composed: true,
})
);
}, view);
await expect(dialog).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(dialog.locator(element)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
}).toPass({ timeout: SHELL_TIMEOUT });
const dialog = await openMoreInfoDialog(
page,
"light.test_light",
view,
element
);
// Each view should render its own characteristic content, not just an
// empty shell.
@@ -464,6 +447,88 @@ test.describe("Weather more-info deep link", () => {
});
});
test.describe("More-info dialog URL cleanup", () => {
test("strips the deep-link params on a plain close", async ({ page }) => {
const errors = trackPageErrors(page);
// An exact route so no default-page redirect races the dialog open.
await goToPanel(page, "/?scenario=light-more-info#/config/dashboard");
const dialog = await openMoreInfoDialog(page, "light.test_light");
await expect(page).toHaveURL(/more-info-entity-id=light\.test_light/);
await dialog.getByRole("button", { name: "Close" }).click();
// The dialog re-renders empty once its close cleanup has run.
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(0, {
timeout: QUICK_TIMEOUT,
});
await expect(page).not.toHaveURL(/more-info-entity-id/);
await expect(page).toHaveURL(/#\/config\/dashboard/);
expectNoPageErrors(errors);
});
test.describe("when navigation closes the dialog", () => {
// --ha-dialog-hide-duration only applies in dialog mode; the bottom sheet
// hardcodes its animation duration, so pin a desktop viewport on every
// project to keep the slow-close setup below effective.
test.use({ viewport: { width: 1280, height: 800 } });
test("keeps the new URL when navigation outpaces the close transition", async ({
page,
}) => {
const errors = trackPageErrors(page);
// An exact route so no default-page redirect races the dialog open.
await goToPanel(page, "/?scenario=light-more-info#/config/dashboard");
const dialog = await openMoreInfoDialog(page, "light.test_light");
await expect(page).toHaveURL(/more-info-entity-id=light\.test_light/);
// Make the hide transition outlast navigate()'s dialog-close wait so
// the navigation commits its URL while the dialog is still closing,
// like on a slow device or with a long themed animation.
await page.evaluate(() => {
document.documentElement.style.setProperty(
"--ha-dialog-hide-duration",
"1200ms"
);
});
// Navigate through a synthetic same-origin link: the dialog scrim
// blocks real link clicks and the dialog's own edit/device actions are
// hidden in the demo build, while navigate() closes open dialogs the
// same way for all of them.
await page.evaluate(() => {
const anchor = document.createElement("a");
anchor.href = "/history";
document.body.append(anchor);
anchor.click();
anchor.remove();
});
await expect(page).toHaveURL(/#\/history/, { timeout: QUICK_TIMEOUT });
// The navigation must commit while the dialog is still closing,
// otherwise this test no longer covers the regression.
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(1);
// The dialog re-renders empty once its close cleanup has run.
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(0, {
timeout: QUICK_TIMEOUT,
});
// The cleanup must not rewrite the URL back to the pre-dialog page.
await expect(page).toHaveURL(/#\/history/);
await expect(page).not.toHaveURL(/more-info-entity-id/);
expectNoPageErrors(errors);
});
});
});
// ---------------------------------------------------------------------------
// Theming
// ---------------------------------------------------------------------------
+32
View File
@@ -27,6 +27,38 @@ export async function goToPanel(page: Page, path: string) {
]);
}
// The hass-more-info event is one-shot: if it lands before the shell's
// listener is attached it is silently dropped. Re-dispatching is idempotent
// (showDialog just resets the dialog to the requested view), so poll the
// dispatch until the requested view actually renders.
export async function openMoreInfoDialog(
page: Page,
entityId: string,
view?: string,
readyElement = "ha-more-info-info"
) {
const dialog = page.locator("ha-more-info-dialog");
await expect(async () => {
await page.evaluate(
(detail) => {
document.querySelector("ha-test")?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail,
bubbles: true,
composed: true,
})
);
},
view ? { entityId, view } : { entityId }
);
await expect(dialog).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(dialog.locator(readyElement)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
}).toPass({ timeout: SHELL_TIMEOUT });
return dialog;
}
export const appMain = (page: Page) => page.locator(APP_MAIN_SELECTOR);
export const appSidebar = (page: Page) => page.locator(APP_SIDEBAR_SELECTOR);
+197
View File
@@ -120,6 +120,50 @@ class RaceRouter extends HassRouterPage {
}
}
class PathPanel extends HTMLElement {
public itemId = "";
}
class DashPanel extends HTMLElement {}
class PathRouter extends HassRouterPage {
public editLoads = 0;
protected routerOptions: RouterOptions = {
routes: {
dashboard: { tag: "test-dash-panel", cache: true },
edit: {
tag: "test-path-panel",
cache: true,
itemId: true,
load: () => {
this.editLoads += 1;
return Promise.resolve();
},
},
view: { tag: "test-path-panel", cache: true },
},
};
protected updatePageEl(el: PathPanel) {
if (this._currentPage === "edit" || this._currentPage === "view") {
el.itemId = this.routeTail.path.slice(1);
}
}
}
class ParentRouter extends HassRouterPage {
protected routerOptions: RouterOptions = {
routes: {
automation: { tag: "test-path-router" },
},
};
protected updatePageEl(el: PathRouter) {
el.route = this.routeTail;
}
}
customElements.define("test-router", TestRouter);
customElements.define("test-immediate-panel", ImmediatePanel);
customElements.define("test-deferred-panel", DeferredPanel);
@@ -129,6 +173,10 @@ customElements.define("test-swap-router", SwapRouter);
customElements.define("test-swap-first-panel", SwapFirstPanel);
customElements.define("test-swap-second-panel", SwapSecondPanel);
customElements.define("test-race-router", RaceRouter);
customElements.define("test-path-panel", PathPanel);
customElements.define("test-dash-panel", DashPanel);
customElements.define("test-path-router", PathRouter);
customElements.define("test-parent-router", ParentRouter);
let router: TestRouter | undefined;
@@ -294,6 +342,151 @@ describe("HassRouterPage update propagation during load", () => {
});
});
describe("HassRouterPage item ids", () => {
it("recreates the page when the item id changes", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
expect(first.itemId).toBe("a");
expect(element.editLoads).toBe(1);
element.route = { prefix: "/config/automation", path: "/edit/b" };
await element.updateComplete;
const second = element.lastElementChild as PathPanel;
expect(second).not.toBe(first);
expect(second.itemId).toBe("b");
expect(first.isConnected).toBe(false);
expect(element.editLoads).toBe(2);
element.remove();
});
it("does not cache a page that has an item id", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
element.route = { prefix: "/config/automation", path: "/dashboard" };
await element.updateComplete;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
const second = element.lastElementChild as PathPanel;
expect(second).not.toBe(first);
expect(second.itemId).toBe("a");
element.remove();
});
it("still caches pages that have no item id", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/dashboard" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as DashPanel;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
element.route = { prefix: "/config/automation", path: "/dashboard" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
element.remove();
});
it("keeps the same page when the item id is unchanged", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
expect(first.itemId).toBe("a");
element.remove();
});
it("reuses a page with a one-segment tail when itemId is not set", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/lovelace", path: "/view/0" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
expect(first.itemId).toBe("0");
element.route = { prefix: "/lovelace", path: "/view/1" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
expect(first.itemId).toBe("1");
element.remove();
});
it("does not recreate a nested router when opening an item from the dashboard", async () => {
const element = document.createElement(
"test-parent-router"
) as ParentRouter;
element.route = { prefix: "/config", path: "/automation/dashboard" };
document.body.append(element);
await element.updateComplete;
const nested = element.lastElementChild as PathRouter;
expect(nested).toBeInstanceOf(PathRouter);
await nested.updateComplete;
const dashboard = nested.lastElementChild as DashPanel;
expect(dashboard).toBeInstanceOf(DashPanel);
element.route = { prefix: "/config", path: "/automation/edit/a" };
await element.updateComplete;
await nested.updateComplete;
expect(element.lastElementChild).toBe(nested);
const editor = nested.lastElementChild as PathPanel;
expect(editor.itemId).toBe("a");
element.remove();
});
it("does not recreate a nested router when only its child's id changes", async () => {
const element = document.createElement(
"test-parent-router"
) as ParentRouter;
element.route = { prefix: "/config", path: "/automation/edit/a" };
document.body.append(element);
await element.updateComplete;
const nested = element.lastElementChild as PathRouter;
expect(nested).toBeInstanceOf(PathRouter);
await nested.updateComplete;
const firstLeaf = nested.lastElementChild as PathPanel;
expect(firstLeaf.itemId).toBe("a");
element.route = { prefix: "/config", path: "/automation/edit/b" };
await element.updateComplete;
await nested.updateComplete;
expect(element.lastElementChild).toBe(nested);
const secondLeaf = nested.lastElementChild as PathPanel;
expect(secondLeaf).not.toBe(firstLeaf);
expect(secondLeaf.itemId).toBe("b");
element.remove();
});
});
declare global {
interface HTMLElementTagNameMap {
"test-router": TestRouter;
@@ -306,5 +499,9 @@ declare global {
"test-swap-first-panel": SwapFirstPanel;
"test-swap-second-panel": SwapSecondPanel;
"test-race-router": RaceRouter;
"test-path-panel": PathPanel;
"test-dash-panel": DashPanel;
"test-path-router": PathRouter;
"test-parent-router": ParentRouter;
}
}
@@ -0,0 +1,53 @@
import { describe, expect, test, vi } from "vitest";
import { goBack, navigate } from "../../../../src/common/navigate";
import type * as NavigateModule from "../../../../src/common/navigate";
import "../../../../src/panels/config/automation/ha-automation-editor";
import type { HaAutomationEditor } from "../../../../src/panels/config/automation/ha-automation-editor";
import { createMockHass } from "../../../fixtures/hass";
vi.mock("../../../../src/common/navigate", async (importOriginal) => {
const actual = await importOriginal<typeof NavigateModule>();
return {
...actual,
goBack: vi.fn(),
navigate: vi.fn(),
};
});
vi.mock("../../../../src/dialogs/generic/show-dialog-box", () => ({
showAlertDialog: vi.fn(async () => undefined),
showConfirmationDialog: vi.fn(async () => true),
}));
describe("automation editor disconnected load", () => {
test("ignores a config fetch that 404s after the editor is disconnected", async () => {
const el = document.createElement(
"ha-automation-editor"
) as HaAutomationEditor;
const hass = createMockHass();
let rejectLoad!: (reason: { status_code: number }) => void;
(hass as any).callApi = vi.fn(
() =>
new Promise((_resolve, reject) => {
rejectLoad = reject;
})
);
el.hass = hass;
el.automations = [];
let connected = true;
Object.defineProperty(el, "isConnected", {
configurable: true,
get: () => connected,
});
const load = (el as any).loadConfig("missing");
connected = false;
rejectLoad({ status_code: 404 });
await load;
expect(goBack).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -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);
});
});
+247 -3
View File
@@ -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"
@@ -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"
@@ -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,6 +10245,7 @@ __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"
@@ -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"
@@ -11757,6 +11908,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"
@@ -11920,7 +12098,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 +12204,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 +12977,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 +13132,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 +13232,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 +13282,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 +13585,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 +14967,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"