mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-28 18:07:14 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
709d22e777 | ||
|
|
cb4e925321 | ||
|
|
69c1735ce6 | ||
|
|
be6461ed9a |
@@ -75,5 +75,3 @@ test/e2e/app/dist/
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
# Downloaded map glyph and sprite archives
|
||||
.map-assets/
|
||||
|
||||
@@ -108,10 +108,6 @@ async function copyMapPanel(staticDir) {
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
copyFileDir(
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
|
||||
@@ -1,137 +1,25 @@
|
||||
// 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.
|
||||
// Generates the MapLibre styles for the vector base map.
|
||||
//
|
||||
// Glyphs and sprites come from pinned VersaTiles releases, cached locally and
|
||||
// verified against a digest.
|
||||
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
|
||||
// is what lets them be requested with an application User-Agent and without a
|
||||
// referrer. The styles stay here because they come from @versatiles/style and
|
||||
// core has no node toolchain to regenerate them with.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { colorful, eclipse } from "@versatiles/style";
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import { extract } from "tar";
|
||||
import paths from "../paths.cjs";
|
||||
import { addLatinLabels } from "./map-labels.js";
|
||||
|
||||
// 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 PROXY_PATH = "/api/map_tiles";
|
||||
const TILEJSON_URL = `${PROXY_PATH}/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.
|
||||
// the proxy move the attribution and zoom range too, not just the URLs.
|
||||
const TILEJSON_FIELDS = [
|
||||
"tiles",
|
||||
"attribution",
|
||||
@@ -164,46 +52,25 @@ const useTileJson = (name, 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` }],
|
||||
glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
|
||||
sprite: [{ id: "basics", url: `${PROXY_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,
|
||||
}),
|
||||
await Promise.all(
|
||||
// Both themes up front: dark is a real cartography, not an inverted raster.
|
||||
...[
|
||||
[
|
||||
["light", colorful],
|
||||
["dark", eclipse],
|
||||
].map(([name, builder]) => {
|
||||
const style = addLatinLabels(useTileJson(name, builder(styleOptions)));
|
||||
assertBoldStaysOnRefs(name, style);
|
||||
return writeFile(
|
||||
].map(([name, builder]) =>
|
||||
writeFile(
|
||||
path.join(outputDir, `${name}.json`),
|
||||
JSON.stringify(style)
|
||||
);
|
||||
}),
|
||||
]);
|
||||
JSON.stringify(useTileJson(name, builder(styleOptions)))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Shared so it does not have to be wired into every pipeline separately.
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// Adds the English name to labels whose local name is not in Latin script.
|
||||
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
|
||||
|
||||
const NAME = ["get", "name"];
|
||||
const NAME_EN = ["get", "name_en"];
|
||||
|
||||
// Strings compare by code point: anything from Basic Latin up to Latin
|
||||
// Extended-B, digits and punctuation included.
|
||||
const IS_LATIN = ["<", NAME, "ɐ"];
|
||||
|
||||
const ENGLISH_SCALE = 0.8;
|
||||
|
||||
// Streets are line-placed and cannot break lines.
|
||||
const withEnglish = (placement) =>
|
||||
placement === "line"
|
||||
? ["concat", NAME, " (", NAME_EN, ")"]
|
||||
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
|
||||
|
||||
const isNameLabel = (layer) =>
|
||||
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
|
||||
|
||||
export const addLatinLabels = (style) => ({
|
||||
...style,
|
||||
layers: style.layers.map((layer) =>
|
||||
isNameLabel(layer)
|
||||
? {
|
||||
...layer,
|
||||
layout: {
|
||||
...layer.layout,
|
||||
"text-field": [
|
||||
"case",
|
||||
IS_LATIN,
|
||||
NAME,
|
||||
["!", ["has", "name_en"]],
|
||||
NAME,
|
||||
withEnglish(layer.layout["symbol-placement"]),
|
||||
],
|
||||
},
|
||||
}
|
||||
: layer
|
||||
),
|
||||
});
|
||||
+4
-5
@@ -75,7 +75,6 @@
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
@@ -110,7 +109,7 @@
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.4.0",
|
||||
"js-yaml": "5.3.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -118,7 +117,7 @@
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.11",
|
||||
"marked": "18.0.10",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -180,7 +179,7 @@
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.9.1",
|
||||
"eslint": "10.9.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -217,7 +216,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.11",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
@@ -19,6 +19,7 @@ export const setupLeafletMap = async (
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
darkMode?: boolean;
|
||||
token?: string;
|
||||
}
|
||||
): Promise<LeafletMapSetup> => {
|
||||
if (!mapElement.parentNode) {
|
||||
@@ -60,7 +61,8 @@ export const setupLeafletMap = async (
|
||||
const baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
initialView?.darkMode ?? false
|
||||
initialView?.darkMode ?? false,
|
||||
initialView?.token
|
||||
);
|
||||
|
||||
return { map, leaflet: Leaflet, baseLayer };
|
||||
|
||||
+134
-91
@@ -1,42 +1,40 @@
|
||||
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
|
||||
import type { Map as LeafletMap } from "leaflet";
|
||||
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
|
||||
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
|
||||
import {
|
||||
MAP_TILES_PATH,
|
||||
refreshMapTilesToken,
|
||||
subscribeMapTilesToken,
|
||||
withMapTilesToken,
|
||||
} from "../../data/map_tiles";
|
||||
|
||||
// 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.
|
||||
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
|
||||
// TileJSON, deliberately: it follows whoever serves the tiles.
|
||||
const VECTOR_STYLES = {
|
||||
light: "/static/map/light.json",
|
||||
dark: "/static/map/dark.json",
|
||||
} as const;
|
||||
|
||||
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
|
||||
// worker, hence a URL rather than an import.
|
||||
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
|
||||
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
|
||||
// OSM serves no @2x variant.
|
||||
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
|
||||
const OSM_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
|
||||
// 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 =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
|
||||
'© <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.
|
||||
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
|
||||
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
|
||||
const CONTEXT_RESTORE_GRACE = 2000;
|
||||
const RECOVERY_THROTTLE = 30000;
|
||||
|
||||
// On the map, not the layer: 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.
|
||||
// On the map, not the layer: marker clustering throws without a maximum. The
|
||||
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
|
||||
export const MAP_MIN_ZOOM = 1;
|
||||
export const MAP_MAX_ZOOM = 20;
|
||||
|
||||
// Leaflet substitutes any option into the URL template; its types do not.
|
||||
type TokenTileLayerOptions = TileLayerOptions & { token?: string };
|
||||
|
||||
export interface MapBaseLayer {
|
||||
// A no-op for raster, which has no dark variant and is inverted in CSS.
|
||||
setDarkMode: (darkMode: boolean) => void;
|
||||
@@ -44,7 +42,7 @@ export interface MapBaseLayer {
|
||||
|
||||
let webGL2Supported: boolean | undefined;
|
||||
|
||||
// Rules out iOS below 15, older Android tablets, and blocklisted drivers.
|
||||
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
|
||||
const supportsWebGL2 = (): boolean => {
|
||||
if (webGL2Supported === undefined) {
|
||||
try {
|
||||
@@ -59,11 +57,35 @@ const supportsWebGL2 = (): boolean => {
|
||||
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.
|
||||
// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would
|
||||
// mangle its {fontstack} and {range} placeholders.
|
||||
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
|
||||
// its glyphs and sprites, which is why those come from VersaTiles.
|
||||
const DEMO_UPSTREAM = {
|
||||
tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json",
|
||||
assets: "https://tiles.versatiles.org/assets",
|
||||
};
|
||||
|
||||
const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
|
||||
style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`;
|
||||
style.sprite = [
|
||||
{ id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` },
|
||||
];
|
||||
Object.values(style.sources).forEach((source) => {
|
||||
if ("url" in source) {
|
||||
source.url = DEMO_UPSTREAM.tilejson;
|
||||
}
|
||||
});
|
||||
return style;
|
||||
};
|
||||
|
||||
const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
const style: StyleSpecification = await (await fetch(url)).json();
|
||||
|
||||
if (__DEMO__) {
|
||||
return useDemoUpstream(style);
|
||||
}
|
||||
|
||||
if (typeof style.sprite === "string") {
|
||||
style.sprite = new URL(style.sprite, location.href).href;
|
||||
} else if (Array.isArray(style.sprite)) {
|
||||
@@ -75,39 +97,28 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
return style;
|
||||
};
|
||||
|
||||
// Global to MapLibre, and it throws when set twice.
|
||||
let rtlTextPluginRequested = false;
|
||||
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
if (rtlTextPluginRequested) {
|
||||
return;
|
||||
}
|
||||
rtlTextPluginRequested = true;
|
||||
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
|
||||
() => {
|
||||
// RTL labels stay reversed; everything else still renders.
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createVectorLayer = async (
|
||||
createLayer: typeof maplibreGL,
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean
|
||||
darkMode: boolean,
|
||||
token: string | undefined
|
||||
): Promise<MapBaseLayer | undefined> => {
|
||||
let layer: ReturnType<typeof maplibreGL> | undefined;
|
||||
|
||||
try {
|
||||
layer = createLayer({
|
||||
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
|
||||
// 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",
|
||||
// Absolute, or the worker fetching tiles cannot resolve them.
|
||||
transformRequest: (url) => ({
|
||||
url: withMapTilesToken(url),
|
||||
// OSM asks a website for a referrer, and the demo has no instance
|
||||
// hostname to leak.
|
||||
referrerPolicy: __DEMO__ ? "origin" : undefined,
|
||||
}),
|
||||
});
|
||||
// The plugin builds the MapLibre map in `onAdd`, so a refused context, a
|
||||
// blocked worker or a rejected blob URL throws here. Keep it inside the
|
||||
// guard or those lose the raster fallback.
|
||||
// The plugin builds the MapLibre map in `onAdd`, so a refused context or a
|
||||
// blocked worker throws here. Keep it guarded or those lose the fallback.
|
||||
layer.addTo(map);
|
||||
} catch {
|
||||
if (layer) {
|
||||
@@ -120,14 +131,14 @@ const createVectorLayer = async (
|
||||
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.
|
||||
// Tracked apart so a failed request rolls back to what is displayed, not to
|
||||
// the opposite of what it asked - which with several in flight differs.
|
||||
let appliedDarkMode = darkMode;
|
||||
let requestedDarkMode = darkMode;
|
||||
// Styles are fetched, so only the newest request may touch the map.
|
||||
let latestRequest = 0;
|
||||
let vector = true;
|
||||
let refused = false;
|
||||
|
||||
const glMap = layer.getMaplibreMap();
|
||||
let fallbackTimeout: number | undefined;
|
||||
@@ -148,13 +159,12 @@ const createVectorLayer = async (
|
||||
} catch {
|
||||
// Nothing left to detach.
|
||||
}
|
||||
createRasterLayer(leaflet, map);
|
||||
createRasterLayer(leaflet, map, token);
|
||||
};
|
||||
|
||||
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.
|
||||
// Backgrounding drops it too, and there it comes back on return.
|
||||
if (!vector || document.hidden) {
|
||||
return;
|
||||
}
|
||||
@@ -177,68 +187,101 @@ const createVectorLayer = async (
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
|
||||
const applyStyle = (newDarkMode: boolean) => {
|
||||
const request = ++latestRequest;
|
||||
|
||||
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
|
||||
.then((style) => {
|
||||
if (request === latestRequest) {
|
||||
appliedDarkMode = newDarkMode;
|
||||
refused = false;
|
||||
layer.getMaplibreMap()?.setStyle(style);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (request === latestRequest) {
|
||||
requestedDarkMode = appliedDarkMode;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// A refused request leaves the source dead: the TileJSON is fetched once and
|
||||
// is never retried, so the style has to be applied again once there is a new
|
||||
// token. Throttled, or a proxy refusing for another reason loops.
|
||||
let lastRecovery = 0;
|
||||
glMap.on("error", (event) => {
|
||||
if ((event.error as { status?: number } | undefined)?.status !== 403) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
lastRecovery = Date.now();
|
||||
refused = true;
|
||||
refreshMapTilesToken();
|
||||
});
|
||||
|
||||
const unsubscribeToken = subscribeMapTilesToken(() => {
|
||||
if (vector && refused) {
|
||||
applyStyle(requestedDarkMode);
|
||||
}
|
||||
});
|
||||
map.on("unload", unsubscribeToken);
|
||||
|
||||
return {
|
||||
setDarkMode: (newDarkMode: boolean) => {
|
||||
if (!vector || newDarkMode === requestedDarkMode) {
|
||||
return;
|
||||
}
|
||||
requestedDarkMode = newDarkMode;
|
||||
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;
|
||||
}
|
||||
});
|
||||
applyStyle(newDarkMode);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createRasterLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap
|
||||
map: LeafletMap,
|
||||
token: string | undefined
|
||||
): 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,
|
||||
}
|
||||
)
|
||||
const layer = leaflet
|
||||
.tileLayer(RASTER_TILE_URL, {
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
// Leaflet throws on an undefined template variable, so no token means an
|
||||
// empty one: the tiles 403 and the markers still draw.
|
||||
token: token ?? "",
|
||||
} as TokenTileLayerOptions)
|
||||
.addTo(map);
|
||||
|
||||
// Substituted per request, so a refreshed token needs no new layer.
|
||||
const unsubscribe = subscribeMapTilesToken((newToken) => {
|
||||
(layer.options as TokenTileLayerOptions).token = newToken;
|
||||
// Tiles that 403'd are cached as failures; only a redraw asks again.
|
||||
layer.redraw();
|
||||
});
|
||||
map.on("unload", unsubscribe);
|
||||
|
||||
return { setDarkMode: () => undefined };
|
||||
};
|
||||
|
||||
export const createBaseLayer = async (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean
|
||||
darkMode: boolean,
|
||||
token: string | undefined
|
||||
): Promise<MapBaseLayer> => {
|
||||
if (supportsWebGL2()) {
|
||||
let vectorLayer: MapBaseLayer | undefined;
|
||||
try {
|
||||
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
|
||||
import("@maplibre/maplibre-gl-leaflet"),
|
||||
import("maplibre-gl"),
|
||||
]);
|
||||
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
|
||||
const { maplibreGL: createLayer } =
|
||||
await import("@maplibre/maplibre-gl-leaflet");
|
||||
vectorLayer = await createVectorLayer(
|
||||
createLayer,
|
||||
leaflet,
|
||||
map,
|
||||
darkMode
|
||||
darkMode,
|
||||
token
|
||||
);
|
||||
} catch {
|
||||
// No chunk, no vector map - but still a map.
|
||||
@@ -247,5 +290,5 @@ export const createBaseLayer = async (
|
||||
return vectorLayer;
|
||||
}
|
||||
}
|
||||
return createRasterLayer(leaflet, map);
|
||||
return createRasterLayer(leaflet, map, token);
|
||||
};
|
||||
|
||||
@@ -95,10 +95,6 @@ export class HaDrawer extends LitElement {
|
||||
}
|
||||
|
||||
private _handleAfterHide(ev: Event) {
|
||||
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this.open = false;
|
||||
fireEvent(this, "hass-drawer-closed");
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
getSelectorInitialValue,
|
||||
getSelectorInitialValueOrUndefined,
|
||||
} from "./get-selector-initial-value";
|
||||
import type { Selector } from "../../data/selector";
|
||||
import type { HaFormData, HaFormSchema } from "./types";
|
||||
|
||||
interface ComputeInitialHaFormDataOptions {
|
||||
skipUnsupportedSelectors?: boolean;
|
||||
}
|
||||
|
||||
const setDefaultValue = (
|
||||
field: HaFormSchema,
|
||||
value: HaFormData | undefined
|
||||
@@ -25,8 +18,7 @@ const setDefaultValue = (
|
||||
};
|
||||
|
||||
export const computeInitialHaFormData = (
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[],
|
||||
options?: ComputeInitialHaFormDataOptions
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[]
|
||||
): Record<string, any> => {
|
||||
const data = {};
|
||||
schema.forEach((field) => {
|
||||
@@ -41,7 +33,7 @@ export const computeInitialHaFormData = (
|
||||
} else if ("default" in field) {
|
||||
data[field.name] = setDefaultValue(field, field.default);
|
||||
} else if (field.type === "expandable") {
|
||||
const expandableData = computeInitialHaFormData(field.schema, options);
|
||||
const expandableData = computeInitialHaFormData(field.schema);
|
||||
if (field.required || Object.keys(expandableData).length) {
|
||||
// Only add expandable data if it's required or any of its children have initial values.
|
||||
data[field.name] = expandableData;
|
||||
@@ -70,11 +62,104 @@ export const computeInitialHaFormData = (
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("selector" in field) {
|
||||
const initialValue = options?.skipUnsupportedSelectors
|
||||
? getSelectorInitialValueOrUndefined(field.selector)
|
||||
: getSelectorInitialValue(field.selector);
|
||||
if (initialValue !== undefined) {
|
||||
data[field.name] = initialValue;
|
||||
const selector: Selector = field.selector;
|
||||
|
||||
if ("device" in selector) {
|
||||
data[field.name] = selector.device?.multiple ? [] : "";
|
||||
} else if ("entity" in selector) {
|
||||
data[field.name] = selector.entity?.multiple ? [] : "";
|
||||
} else if ("area" in selector) {
|
||||
data[field.name] = selector.area?.multiple ? [] : "";
|
||||
} else if ("label" in selector) {
|
||||
data[field.name] = selector.label?.multiple ? [] : "";
|
||||
} else if ("boolean" in selector) {
|
||||
data[field.name] = false;
|
||||
} else if (
|
||||
"addon" in selector ||
|
||||
"attribute" in selector ||
|
||||
"file" in selector ||
|
||||
"icon" in selector ||
|
||||
"serial_port" in selector ||
|
||||
"template" in selector ||
|
||||
"text" in selector ||
|
||||
"theme" in selector ||
|
||||
"object" in selector
|
||||
) {
|
||||
data[field.name] = "";
|
||||
} else if ("number" in selector) {
|
||||
data[field.name] = selector.number?.min ?? 0;
|
||||
} else if ("select" in selector) {
|
||||
if (selector.select?.options.length) {
|
||||
const firstOption = selector.select.options[0];
|
||||
const val =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
data[field.name] = selector.select.multiple ? [val] : val;
|
||||
}
|
||||
} else if ("country" in selector) {
|
||||
if (selector.country?.countries?.length) {
|
||||
data[field.name] = selector.country.countries[0];
|
||||
}
|
||||
} else if ("language" in selector) {
|
||||
if (selector.language?.languages?.length) {
|
||||
data[field.name] = selector.language.languages[0];
|
||||
}
|
||||
} else if ("duration" in selector) {
|
||||
data[field.name] = {
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("time" in selector) {
|
||||
data[field.name] = "00:00:00";
|
||||
} else if ("date" in selector || "datetime" in selector) {
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
data[field.name] = `${now}T00:00:00`;
|
||||
} else if ("color_rgb" in selector) {
|
||||
data[field.name] = [0, 0, 0];
|
||||
} else if ("color_temp" in selector) {
|
||||
data[field.name] = selector.color_temp?.min_mireds ?? 153;
|
||||
} else if (
|
||||
"action" in selector ||
|
||||
"trigger" in selector ||
|
||||
"condition" in selector
|
||||
) {
|
||||
data[field.name] = [];
|
||||
} else if ("media" in selector || "target" in selector) {
|
||||
data[field.name] = {};
|
||||
} else if ("state" in selector) {
|
||||
data[field.name] = selector.state?.multiple ? [] : "";
|
||||
} else if ("choose" in selector) {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
if (!firstChoice) {
|
||||
data[field.name] = {};
|
||||
} else {
|
||||
data[field.name] = {
|
||||
active_choice: firstChoice,
|
||||
[firstChoice]: computeInitialHaFormData([
|
||||
{
|
||||
name: firstChoice,
|
||||
selector: selector.choose.choices[firstChoice].selector,
|
||||
},
|
||||
])[firstChoice],
|
||||
};
|
||||
}
|
||||
} else if ("numeric_threshold" in selector) {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
data[field.name] =
|
||||
type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,89 +1,25 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorFallbackValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_FALLBACK_VALUES = {
|
||||
action: undefined,
|
||||
addon: undefined,
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: undefined,
|
||||
areas_display: undefined,
|
||||
attribute: undefined,
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: undefined,
|
||||
color_rgb: undefined,
|
||||
condition: undefined,
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: undefined,
|
||||
date: undefined,
|
||||
datetime: undefined,
|
||||
device: undefined,
|
||||
device_class: undefined,
|
||||
duration: undefined,
|
||||
entity: undefined,
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: undefined,
|
||||
floor: undefined,
|
||||
label: undefined,
|
||||
language: undefined,
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: undefined,
|
||||
object: undefined,
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: undefined,
|
||||
selector: undefined,
|
||||
serial_port: undefined,
|
||||
state: undefined,
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: undefined,
|
||||
template: undefined,
|
||||
text: undefined,
|
||||
time: undefined,
|
||||
icon: undefined,
|
||||
media: undefined,
|
||||
theme: undefined,
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: undefined,
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
},
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorFallbackValues;
|
||||
import type { Selector } from "../../data/selector";
|
||||
|
||||
/**
|
||||
* Value a selector already displays when no field value is set.
|
||||
* Used when enabling an optional service/trigger/condition field.
|
||||
*/
|
||||
export const getSelectorFallbackValue = (selector: Selector): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
|
||||
|
||||
return fallbackValue?.(selector as never);
|
||||
if ("constant" in selector) {
|
||||
return selector.constant?.value;
|
||||
}
|
||||
if ("boolean" in selector) {
|
||||
return false;
|
||||
}
|
||||
if ("number" in selector) {
|
||||
return selector.number?.min ?? 0;
|
||||
}
|
||||
if ("color_temp" in selector) {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorInitialValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_INITIAL_VALUES = {
|
||||
action: () => [],
|
||||
addon: () => "",
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: (selector) => (selector.area?.multiple ? [] : ""),
|
||||
areas_display: undefined,
|
||||
attribute: () => "",
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: (selector) => {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
|
||||
if (!firstChoice) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const childValue = getSelectorInitialValueOrUndefined(
|
||||
selector.choose.choices[firstChoice].selector
|
||||
);
|
||||
|
||||
return childValue === undefined
|
||||
? { active_choice: firstChoice }
|
||||
: { active_choice: firstChoice, [firstChoice]: childValue };
|
||||
},
|
||||
color_rgb: () => [0, 0, 0],
|
||||
condition: () => [],
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: (selector) => selector.country?.countries?.[0],
|
||||
date: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
|
||||
datetime: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
|
||||
device: (selector) => (selector.device?.multiple ? [] : ""),
|
||||
device_class: (selector) =>
|
||||
selector.device_class?.multiple ? [] : undefined,
|
||||
duration: () => ({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
}),
|
||||
entity: (selector) => (selector.entity?.multiple ? [] : ""),
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: () => "",
|
||||
floor: undefined,
|
||||
label: (selector) => (selector.label?.multiple ? [] : ""),
|
||||
language: (selector) => selector.language?.languages?.[0],
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: (selector) => {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
|
||||
return type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
},
|
||||
object: (selector) => (selector.object?.multiple ? [] : ""),
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: (selector) => {
|
||||
const select = selector.select;
|
||||
|
||||
if (!select?.options.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstOption = select.options[0];
|
||||
const value =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
|
||||
return select.multiple ? [value] : value;
|
||||
},
|
||||
selector: undefined,
|
||||
serial_port: () => "",
|
||||
state: (selector) => (selector.state?.multiple ? [] : ""),
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: () => ({}),
|
||||
template: () => "",
|
||||
text: (selector) => (selector.text?.multiple ? [] : ""),
|
||||
time: () => "00:00:00",
|
||||
icon: () => "",
|
||||
media: () => ({}),
|
||||
theme: () => "",
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: () => [],
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => selector.color_temp?.min_mireds ?? 153,
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorInitialValues;
|
||||
|
||||
export const getSelectorInitialValueOrUndefined = (
|
||||
selector: Selector
|
||||
): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
|
||||
};
|
||||
|
||||
export const getSelectorInitialValue = (selector: Selector): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const initialValue = SELECTOR_INITIAL_VALUES[type];
|
||||
|
||||
if (!initialValue) {
|
||||
throw new Error(`Selector ${type} not supported in initial form data`);
|
||||
}
|
||||
|
||||
return initialValue(selector as never);
|
||||
};
|
||||
@@ -67,16 +67,15 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key.
|
||||
// Leaving inputmode unset on a number input gives the "Numbers and
|
||||
// Punctuation" keyboard there, which does include a minus. Other platforms
|
||||
// include a minus on their number keypads, so restrict this workaround to
|
||||
// Safari/WebKit and only when the selector allows negatives: either an
|
||||
// explicit negative min, or no min at all (e.g. the numeric threshold
|
||||
// selector used by the power triggers).
|
||||
const useSafariNegativeKeyboard =
|
||||
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
|
||||
// so negatives can only be typed with the full "text" keyboard. Other
|
||||
// platforms include a minus on their number keypads, so restrict this
|
||||
// workaround to Safari/WebKit and only when the selector allows negatives
|
||||
// (e.g. numeric_state triggers/conditions).
|
||||
const useTextInputMode =
|
||||
isSafari &&
|
||||
(this.selector.number?.min === undefined || this.selector.number.min < 0);
|
||||
this.selector.number?.min !== undefined &&
|
||||
this.selector.number.min < 0;
|
||||
|
||||
const translationKey = this.selector.number?.translation_key;
|
||||
let unit = this.selector.number?.unit_of_measurement;
|
||||
@@ -113,8 +112,8 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
<ha-input
|
||||
.inputmode=${
|
||||
useSafariNegativeKeyboard
|
||||
? undefined
|
||||
useTextInputMode
|
||||
? "text"
|
||||
: this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { ObjectSelector } from "../../data/selector";
|
||||
import { formatSelectorValue } from "../../data/selector/format_selector_value";
|
||||
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
|
||||
import type { HaFormSchema } from "../ha-form/types";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-md-list";
|
||||
@@ -238,14 +237,10 @@ export class HaObjectSelector extends LitElement {
|
||||
private async _addItem(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const schema = this._schema(this.selector);
|
||||
|
||||
const newItem = await showFormDialog(this, {
|
||||
title: this.hass.localize("ui.common.add"),
|
||||
schema,
|
||||
data: computeInitialHaFormData(schema, {
|
||||
skipUnsupportedSelectors: true,
|
||||
}),
|
||||
schema: this._schema(this.selector),
|
||||
data: {},
|
||||
computeLabel: this._computeLabel,
|
||||
computeHelper: this._computeHelper,
|
||||
submitText: this.hass.localize("ui.common.add"),
|
||||
@@ -298,7 +293,7 @@ export class HaObjectSelector extends LitElement {
|
||||
const index = ev.currentTarget.index;
|
||||
|
||||
if (!this.selector.object!.multiple) {
|
||||
fireEvent(this, "value-changed", { value: "" });
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import type { Selector, SelectorType } from "../../data/selector";
|
||||
import type { Selector } from "../../data/selector";
|
||||
import {
|
||||
handleLegacyDeviceSelector,
|
||||
handleLegacyEntitySelector,
|
||||
@@ -70,7 +70,7 @@ const LOAD_ELEMENTS = {
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
ui_time_format: () => import("./ha-selector-ui-time-format"),
|
||||
} satisfies Record<SelectorType, () => Promise<unknown>>;
|
||||
};
|
||||
|
||||
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { MapBaseLayer } from "../../common/map/base-layer";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import { DecoratedMarker } from "../../common/map/decorated_marker";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
@@ -327,11 +328,19 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
this._loading = true;
|
||||
try {
|
||||
// The tiles are proxied by core behind a token, so nothing loads without
|
||||
// one. A host that provides no connection, or a backend without the
|
||||
// proxy, leaves the map without tiles rather than failing to set up.
|
||||
const token = this._connection
|
||||
? await ensureMapTilesToken(this._connection.connection)
|
||||
: undefined;
|
||||
|
||||
const setup = await setupLeafletMap(map, {
|
||||
latitude: this._config?.latitude ?? 52.3731339,
|
||||
longitude: this._config?.longitude ?? 4.8903147,
|
||||
zoom: this.zoom,
|
||||
darkMode: this._darkMode,
|
||||
token,
|
||||
});
|
||||
// Setting up fetches a style, so the element can be gone by now.
|
||||
// `disconnectedCallback` had no map to tear down, and keeping this one
|
||||
|
||||
+5
-15
@@ -1553,8 +1553,7 @@ export const formatConsumptionShort = (
|
||||
hass: HomeAssistant,
|
||||
consumption: number | null,
|
||||
unit: string,
|
||||
targetUnit?: string,
|
||||
displayPrecision?: number
|
||||
targetUnit?: string
|
||||
): string => {
|
||||
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
|
||||
let pickedUnit = unit;
|
||||
@@ -1584,19 +1583,10 @@ export const formatConsumptionShort = (
|
||||
pickedUnit = units[unitIndex];
|
||||
}
|
||||
return (
|
||||
formatNumber(
|
||||
val,
|
||||
hass.locale,
|
||||
displayPrecision !== undefined && pickedUnit === unit
|
||||
? {
|
||||
minimumFractionDigits: displayPrecision,
|
||||
maximumFractionDigits: displayPrecision,
|
||||
}
|
||||
: {
|
||||
maximumFractionDigits:
|
||||
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
|
||||
}
|
||||
) +
|
||||
formatNumber(val, hass.locale, {
|
||||
maximumFractionDigits:
|
||||
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
|
||||
}) +
|
||||
" " +
|
||||
pickedUnit
|
||||
);
|
||||
|
||||
@@ -50,6 +50,9 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
|
||||
azimuth: "°",
|
||||
elevation: "°",
|
||||
},
|
||||
vacuum: {
|
||||
battery_level: "%",
|
||||
},
|
||||
valve: {
|
||||
current_position: "%",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { waitForMs } from "../common/util/wait";
|
||||
|
||||
export const MAP_TILES_PATH = "/api/map_tiles";
|
||||
|
||||
// Well inside the token's lifetime, as util/brands-url.ts does.
|
||||
const TOKEN_REFRESH_MS = 30 * 60 * 1000;
|
||||
|
||||
// Nothing loads without a token, so the first attempts are awaited - briefly,
|
||||
// or a backend without the proxy would hold the map hostage. The rest retry in
|
||||
// the background, for the window after a restart where the WebSocket is up but
|
||||
// the handler is not registered yet.
|
||||
const BLOCKING_DELAYS_MS = [0, 400, 1000];
|
||||
const BACKGROUND_DELAYS_MS = [2000, 5000, 10000, 15000];
|
||||
|
||||
let token: string | undefined;
|
||||
let refreshInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let watchingConnection = false;
|
||||
let activeConnection: Connection | undefined;
|
||||
let refreshing: Promise<void> | undefined;
|
||||
const listeners = new Set<(token: string) => void>();
|
||||
|
||||
const fetchToken = async (connection: Connection): Promise<void> => {
|
||||
const result = await connection.sendMessagePromise<{ token: string }>({
|
||||
type: "map_tiles/access_token",
|
||||
});
|
||||
if (result.token !== token) {
|
||||
token = result.token;
|
||||
listeners.forEach((listener) => listener(token!));
|
||||
}
|
||||
};
|
||||
|
||||
const attempt = async (connection: Connection, delays: number[]) => {
|
||||
/* eslint-disable no-await-in-loop -- retries are intentionally sequential */
|
||||
for (const delay of delays) {
|
||||
if (token) {
|
||||
return;
|
||||
}
|
||||
if (delay) {
|
||||
await waitForMs(delay);
|
||||
}
|
||||
try {
|
||||
await fetchToken(connection);
|
||||
return;
|
||||
} catch {
|
||||
// try next delay
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
};
|
||||
|
||||
export const ensureMapTilesToken = async (
|
||||
connection: Connection
|
||||
): Promise<string | undefined> => {
|
||||
activeConnection = connection;
|
||||
|
||||
if (!token) {
|
||||
await attempt(connection, BLOCKING_DELAYS_MS);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
attempt(connection, BACKGROUND_DELAYS_MS).then(() =>
|
||||
scheduleRefresh(connection)
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
scheduleRefresh(connection);
|
||||
return token;
|
||||
};
|
||||
|
||||
const scheduleRefresh = (connection: Connection) => {
|
||||
if (token && !refreshInterval) {
|
||||
refreshInterval = setInterval(() => {
|
||||
fetchToken(connection).catch(() => {
|
||||
// Keep the current token; the next interval retries.
|
||||
});
|
||||
}, TOKEN_REFRESH_MS);
|
||||
}
|
||||
|
||||
if (!watchingConnection) {
|
||||
watchingConnection = true;
|
||||
// The interval does not fire while the process is suspended, so the token
|
||||
// can be stale before it comes round; reconnecting is the reliable signal.
|
||||
connection.addEventListener("ready", () => {
|
||||
fetchToken(connection).catch(() => {
|
||||
// Nothing to do; the interval keeps trying.
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Forces a new token, for when the current one is refused. Deduplicated: a
|
||||
* refused map produces one of these per tile.
|
||||
*/
|
||||
export const refreshMapTilesToken = (): Promise<void> => {
|
||||
if (!activeConnection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
refreshing ??= fetchToken(activeConnection)
|
||||
.catch(() => {
|
||||
// Leave the old token in place; a reconnect or the interval retries.
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing = undefined;
|
||||
});
|
||||
return refreshing;
|
||||
};
|
||||
|
||||
/** Leaflet bakes its URL template at layer creation, so it needs telling. */
|
||||
export const subscribeMapTilesToken = (
|
||||
listener: (token: string) => void
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* MapLibre hands tile URLs to a worker, which has no document to resolve a
|
||||
* relative URL against, so the result has to be absolute.
|
||||
*/
|
||||
export const withMapTilesToken = (url: string): string => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url, location.href);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (token && parsed.pathname.startsWith(`${MAP_TILES_PATH}/`)) {
|
||||
parsed.searchParams.set("token", token);
|
||||
}
|
||||
|
||||
return parsed.href;
|
||||
};
|
||||
@@ -88,20 +88,6 @@ export type Selector =
|
||||
| UiTimeFormatSelector
|
||||
| BackupLocationSelector;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
export type SelectorType = KeysOfUnion<Selector>;
|
||||
|
||||
type UnionMemberWithKey<U, K extends PropertyKey> = U extends unknown
|
||||
? K extends keyof U
|
||||
? U
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type SelectorForType<T extends SelectorType> = UnionMemberWithKey<
|
||||
Selector,
|
||||
T
|
||||
>;
|
||||
|
||||
export interface ActionSelector {
|
||||
action: {
|
||||
optionsInSidebar?: boolean;
|
||||
|
||||
@@ -116,7 +116,10 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
|
||||
></ha-vacuum-segment-area-mapper>
|
||||
|
||||
<div class="footer">
|
||||
<ha-button @click=${this._save} .disabled=${this._submitting}>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${!this._dirtyState?.isDirty || this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,33 @@ const initRouting = () => {
|
||||
})
|
||||
);
|
||||
|
||||
// Strip the rotating token from the cache key, or every rotation refetches
|
||||
// every tile. The TileJSON is deliberately not matched: it is the switching
|
||||
// point for the tile source, so it stays on the network.
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
/^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.test(url.pathname),
|
||||
new CacheFirst({
|
||||
cacheName: "map-tiles",
|
||||
plugins: [
|
||||
{
|
||||
cacheKeyWillBeUsed: async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("token");
|
||||
return url.href;
|
||||
},
|
||||
},
|
||||
// A stale token gives a 403; caching that would pin the failure.
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7,
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Short-circuit camera/image proxy requests with an expired signature or a
|
||||
// missing/undefined token so they don't hit core and get logged as invalid
|
||||
// login attempts. Registered before the generic /api route below so it wins.
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoize from "memoize-one";
|
||||
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
|
||||
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
|
||||
@@ -78,10 +77,6 @@ export class HAFullCalendar extends LitElement {
|
||||
|
||||
@property({ attribute: "add-fab", type: Boolean }) public addFab = false;
|
||||
|
||||
@property({ attribute: "add-fab-size" }) public addFabSize = "large";
|
||||
|
||||
@property({ attribute: "add-fab-style" }) public addFabStyle = "on_top";
|
||||
|
||||
@property({ attribute: false }) public events: CalendarEvent[] = [];
|
||||
|
||||
@property({ attribute: false }) public calendars: CalendarData[] = [];
|
||||
@@ -177,34 +172,13 @@ export class HAFullCalendar extends LitElement {
|
||||
</ha-icon-button-next>
|
||||
</div>
|
||||
<h1>${this.calendar.view.title}</h1>
|
||||
<div>
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
${
|
||||
this.addFab &&
|
||||
this._hasMutableCalendars &&
|
||||
this.addFabStyle === "header"
|
||||
? html`<ha-button
|
||||
size="s"
|
||||
class="fab-header"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.components.calendar.event.add"
|
||||
)}
|
||||
@click=${this._createEvent}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot=""
|
||||
.path=${mdiPlus}
|
||||
></ha-svg-icon>
|
||||
</ha-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
`
|
||||
: html`
|
||||
<div class="controls">
|
||||
@@ -234,34 +208,13 @@ export class HAFullCalendar extends LitElement {
|
||||
"ui.components.calendar.today"
|
||||
)}</ha-button
|
||||
>
|
||||
<div>
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
${
|
||||
this.addFab &&
|
||||
this._hasMutableCalendars &&
|
||||
this.addFabStyle === "header"
|
||||
? html`<ha-button
|
||||
size="s"
|
||||
class="fab-header"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.components.calendar.event.add"
|
||||
)}
|
||||
@click=${this._createEvent}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot=""
|
||||
.path=${mdiPlus}
|
||||
></ha-svg-icon>
|
||||
</ha-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -272,15 +225,8 @@ export class HAFullCalendar extends LitElement {
|
||||
|
||||
<div id="calendar"></div>
|
||||
${
|
||||
this.addFab &&
|
||||
this._hasMutableCalendars &&
|
||||
this.addFabStyle !== "header"
|
||||
? html`<ha-button
|
||||
size=${this.addFabSize.charAt(0)}
|
||||
class=${classMap({ below: this.addFabStyle === "below" })}
|
||||
slot="fab"
|
||||
@click=${this._createEvent}
|
||||
>
|
||||
this.addFab && this._hasMutableCalendars
|
||||
? html`<ha-button size="l" slot="fab" @click=${this._createEvent}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.calendar.event.add")}
|
||||
</ha-button>`
|
||||
@@ -628,17 +574,6 @@ export class HAFullCalendar extends LitElement {
|
||||
--ha-button-box-shadow: var(--ha-box-shadow-l);
|
||||
}
|
||||
|
||||
ha-button.below[slot="fab"] {
|
||||
position: relative;
|
||||
margin-inline-start: auto;
|
||||
padding-top: var(--ha-space-2);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
--ha-button-box-shadow: none;
|
||||
}
|
||||
|
||||
#calendar {
|
||||
flex-grow: 1;
|
||||
background-color: var(
|
||||
|
||||
@@ -201,7 +201,7 @@ export class DialogVacuumSegmentMapping
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._submitting}
|
||||
.disabled=${this._submitting || !this.isDirtyState}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
|
||||
@@ -12,15 +12,6 @@ function getLQIWidth(lqi: number): number {
|
||||
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
|
||||
}
|
||||
|
||||
const RELATIONSHIP_PRIORITY: Record<string, number> = {
|
||||
Parent: 0,
|
||||
Sibling: 1,
|
||||
NoneOfTheAbove: 2,
|
||||
Child: 3,
|
||||
PreviousChild: 5,
|
||||
};
|
||||
const UNKNOWN_RELATIONSHIP_PRIORITY = 4;
|
||||
|
||||
export function createZHANetworkChartData(
|
||||
devices: ZHADevice[],
|
||||
hass: HomeAssistant,
|
||||
@@ -168,78 +159,38 @@ export function createZHANetworkChartData(
|
||||
existingLinks.push(link);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// For every device whose routing table was empty (so it got no link
|
||||
// above), independently compute its preferred fallback neighbor before
|
||||
// creating any of these links. Doing this device-by-device while
|
||||
// creating links (as before) meant whichever device the backend happens
|
||||
// to list first "claims" the connection, and a sibling router listed
|
||||
// later never gets to contribute its own — often better — choice,
|
||||
// splitting the graph into islands depending on backend device order.
|
||||
const fallbackTargets = new Map<string, { ieee: string; lqi: string }>();
|
||||
devices.forEach((device) => {
|
||||
const hasLink = links.some(
|
||||
(link) => link.source === device.ieee || link.target === device.ieee
|
||||
);
|
||||
if (hasLink) {
|
||||
return;
|
||||
}
|
||||
const neighbors: { ieee: string; lqi: string; relationship?: string }[] =
|
||||
device.neighbors ?? [];
|
||||
if (neighbors.length === 0) {
|
||||
// If there are no neighbors, look for links from other devices
|
||||
devices.forEach((d) => {
|
||||
if (d.neighbors && d.neighbors.length > 0) {
|
||||
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
|
||||
if (neighbor) {
|
||||
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
|
||||
} else if (existingLinks.length === 0) {
|
||||
// If there are no links, create a link to the closest neighbor
|
||||
const neighbors: { ieee: string; lqi: string }[] = device.neighbors ?? [];
|
||||
if (neighbors.length === 0) {
|
||||
// If there are no neighbors, look for links from other devices
|
||||
devices.forEach((d) => {
|
||||
if (d.neighbors && d.neighbors.length > 0) {
|
||||
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
|
||||
if (neighbor) {
|
||||
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
const closestNeighbor = neighbors.sort(
|
||||
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
|
||||
)[0];
|
||||
if (closestNeighbor) {
|
||||
links.push({
|
||||
source: device.ieee,
|
||||
target: closestNeighbor.ieee,
|
||||
value: parseInt(closestNeighbor.lqi),
|
||||
symbolSize: 5,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: style.getPropertyValue("--dark-primary-color"),
|
||||
type: "dotted",
|
||||
},
|
||||
ignoreForceLayout: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Prefer a neighbor the device's own Zigbee stack reports as its
|
||||
// parent: picking by raw LQI alone regularly favors a nearby child end
|
||||
// device (e.g. a plug sitting right next to its router) over the
|
||||
// router's actual uplink, which severs it from the rest of the mesh in
|
||||
// the visualization even though the real network is connected.
|
||||
const closestNeighbor = neighbors.sort((a, b) => {
|
||||
const aPriority =
|
||||
RELATIONSHIP_PRIORITY[a.relationship ?? ""] ??
|
||||
UNKNOWN_RELATIONSHIP_PRIORITY;
|
||||
const bPriority =
|
||||
RELATIONSHIP_PRIORITY[b.relationship ?? ""] ??
|
||||
UNKNOWN_RELATIONSHIP_PRIORITY;
|
||||
const priorityDiff = aPriority - bPriority;
|
||||
return priorityDiff !== 0
|
||||
? priorityDiff
|
||||
: parseInt(b.lqi) - parseInt(a.lqi);
|
||||
})[0];
|
||||
if (closestNeighbor) {
|
||||
fallbackTargets.set(device.ieee, closestNeighbor);
|
||||
}
|
||||
});
|
||||
|
||||
const addedFallbackPairs = new Set<string>();
|
||||
fallbackTargets.forEach((target, ieee) => {
|
||||
const pairKey = [ieee, target.ieee].sort().join("|");
|
||||
if (addedFallbackPairs.has(pairKey)) {
|
||||
return;
|
||||
}
|
||||
addedFallbackPairs.add(pairKey);
|
||||
links.push({
|
||||
source: ieee,
|
||||
target: target.ieee,
|
||||
value: parseInt(target.lqi),
|
||||
symbolSize: 5,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: style.getPropertyValue("--dark-primary-color"),
|
||||
type: "dotted",
|
||||
},
|
||||
ignoreForceLayout: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Now set ignoreForceLayout to false for the best connection of each device
|
||||
|
||||
@@ -112,11 +112,7 @@ class HuiEnergyDistrubutionCard
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (!oldHass) {
|
||||
return true;
|
||||
}
|
||||
const oldStates = oldHass.states;
|
||||
const oldStates = changedProps.get("hass").states;
|
||||
if (
|
||||
this._data?.co2SignalEntity &&
|
||||
this.hass.states[this._data.co2SignalEntity] !==
|
||||
@@ -124,20 +120,6 @@ class HuiEnergyDistrubutionCard
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
this._data &&
|
||||
energySourcesByType(this._data.prefs).gas?.some((source) => {
|
||||
const statId = source.stat_energy_from;
|
||||
return (
|
||||
this.hass.entities[statId]?.display_precision !==
|
||||
oldHass.entities[statId]?.display_precision ||
|
||||
this.hass.states[statId]?.attributes.unit_of_measurement !==
|
||||
oldHass.states[statId]?.attributes.unit_of_measurement
|
||||
);
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (this._data && periodIncludesNow(this._data)) {
|
||||
const batteries = energySourcesByType(this._data.prefs).battery;
|
||||
if (
|
||||
@@ -180,21 +162,6 @@ class HuiEnergyDistrubutionCard
|
||||
const hasBattery = types.battery !== undefined;
|
||||
const hasGas = types.gas !== undefined;
|
||||
const hasWater = types.water !== undefined;
|
||||
const gasUnit = this._data.gasUnit;
|
||||
const gasDisplayPrecisions = types.gas
|
||||
?.filter(
|
||||
(source) =>
|
||||
this.hass.states[source.stat_energy_from]?.attributes
|
||||
.unit_of_measurement === gasUnit
|
||||
)
|
||||
.map(
|
||||
(source) =>
|
||||
this.hass.entities[source.stat_energy_from]?.display_precision
|
||||
)
|
||||
.filter((precision): precision is number => precision !== undefined);
|
||||
const gasDisplayPrecision = gasDisplayPrecisions?.length
|
||||
? Math.max(...gasDisplayPrecisions)
|
||||
: undefined;
|
||||
const hasReturnToGrid =
|
||||
types.grid?.some((source) => source.stat_energy_to) ?? false;
|
||||
|
||||
@@ -470,9 +437,7 @@ class HuiEnergyDistrubutionCard
|
||||
${formatConsumptionShort(
|
||||
this.hass,
|
||||
gasUsage,
|
||||
this._data.gasUnit,
|
||||
undefined,
|
||||
gasDisplayPrecision
|
||||
this._data.gasUnit
|
||||
)}
|
||||
</div>
|
||||
<svg width="80" height="30">
|
||||
|
||||
@@ -11,7 +11,6 @@ import "../../../../components/chart/ha-chart-base";
|
||||
import "../../../../components/ha-card";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
energySourcesByType,
|
||||
getEnergyDataCollection,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
@@ -67,8 +66,6 @@ export class HuiEnergyGasGraphCard
|
||||
|
||||
@state() private _total?: number;
|
||||
|
||||
private _energyData?: EnergyData;
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
@@ -91,68 +88,11 @@ export class HuiEnergyGasGraphCard
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
if (
|
||||
return (
|
||||
hasConfigChanged(this, changedProps) ||
|
||||
changedProps.size > 1 ||
|
||||
!changedProps.has("hass")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (!oldHass) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
this._energyData &&
|
||||
energySourcesByType(this._energyData.prefs).gas?.some((source) => {
|
||||
const statId = source.stat_energy_from;
|
||||
return (
|
||||
this.hass.entities[statId]?.display_precision !==
|
||||
oldHass.entities[statId]?.display_precision ||
|
||||
this.hass.states[statId]?.attributes.unit_of_measurement !==
|
||||
oldHass.states[statId]?.attributes.unit_of_measurement
|
||||
);
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private get _displayPrecision(): number | undefined {
|
||||
if (!this._energyData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const gasDisplayPrecisions = energySourcesByType(this._energyData.prefs)
|
||||
.gas?.filter(
|
||||
(source) =>
|
||||
this.hass.states[source.stat_energy_from]?.attributes
|
||||
.unit_of_measurement === this._unit
|
||||
)
|
||||
.map(
|
||||
(source) =>
|
||||
this.hass.entities[source.stat_energy_from]?.display_precision
|
||||
)
|
||||
.filter((precision): precision is number => precision !== undefined);
|
||||
|
||||
return gasDisplayPrecisions?.length
|
||||
? Math.max(...gasDisplayPrecisions)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private get _gasFormatOptions(): Intl.NumberFormatOptions | undefined {
|
||||
const displayPrecision = this._displayPrecision;
|
||||
|
||||
return displayPrecision !== undefined
|
||||
? {
|
||||
minimumFractionDigits: displayPrecision,
|
||||
maximumFractionDigits: displayPrecision,
|
||||
}
|
||||
: undefined;
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -171,11 +111,7 @@ export class HuiEnergyGasGraphCard
|
||||
? html`<hui-energy-graph-chip
|
||||
.tooltip=${this._formatTotal(this._total)}
|
||||
>
|
||||
${formatNumber(
|
||||
this._total,
|
||||
this.hass.locale,
|
||||
this._gasFormatOptions
|
||||
)}
|
||||
${formatNumber(this._total, this.hass.locale)}
|
||||
${this._unit}
|
||||
</hui-energy-graph-chip>`
|
||||
: nothing
|
||||
@@ -199,7 +135,7 @@ export class HuiEnergyGasGraphCard
|
||||
this._unit,
|
||||
this._compareStart,
|
||||
this._compareEnd,
|
||||
this._displayPrecision ?? this._yAxisFractionDigits
|
||||
this._yAxisFractionDigits
|
||||
)}
|
||||
chart-type="bar"
|
||||
></ha-chart-base>
|
||||
@@ -226,10 +162,7 @@ export class HuiEnergyGasGraphCard
|
||||
private _formatTotal = (total: number) =>
|
||||
this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed",
|
||||
{
|
||||
num: formatNumber(total, this.hass.locale, this._gasFormatOptions),
|
||||
unit: this._unit,
|
||||
}
|
||||
{ num: formatNumber(total, this.hass.locale), unit: this._unit }
|
||||
);
|
||||
|
||||
private _createOptions = memoizeOne(
|
||||
@@ -258,8 +191,6 @@ export class HuiEnergyGasGraphCard
|
||||
);
|
||||
|
||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||
this._energyData = energyData;
|
||||
|
||||
const result = generateEnergyGasGraphData({
|
||||
hass: this.hass,
|
||||
energyData,
|
||||
|
||||
@@ -87,35 +87,11 @@ export class HuiEnergySourcesTableCard
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
if (
|
||||
return (
|
||||
hasConfigChanged(this, changedProps) ||
|
||||
changedProps.size > 1 ||
|
||||
!changedProps.has("hass")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (!oldHass) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
this._data &&
|
||||
energySourcesByType(this._data.prefs).gas?.some((source) => {
|
||||
const statId = source.stat_energy_from;
|
||||
return (
|
||||
this.hass.entities[statId]?.display_precision !==
|
||||
oldHass.entities[statId]?.display_precision ||
|
||||
this.hass.states[statId]?.attributes.unit_of_measurement !==
|
||||
oldHass.states[statId]?.attributes.unit_of_measurement
|
||||
);
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
);
|
||||
}
|
||||
|
||||
protected _renderRow(
|
||||
@@ -132,20 +108,6 @@ export class HuiEnergySourcesTableCard
|
||||
compare: boolean,
|
||||
name?: string
|
||||
) {
|
||||
const displayPrecision =
|
||||
type === "gas" &&
|
||||
this.hass.states[statId]?.attributes.unit_of_measurement === energyUnit
|
||||
? this.hass.entities[statId]?.display_precision
|
||||
: undefined;
|
||||
|
||||
const formatOptions =
|
||||
displayPrecision !== undefined
|
||||
? {
|
||||
minimumFractionDigits: displayPrecision,
|
||||
maximumFractionDigits: displayPrecision,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return html`<tr
|
||||
class="mdc-data-table__row ${classMap({
|
||||
clickable: !isExternalStatistic(statId),
|
||||
@@ -189,8 +151,7 @@ export class HuiEnergySourcesTableCard
|
||||
${
|
||||
compare
|
||||
? html`<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
|
||||
${formatNumber(compareEnergy, this.hass.locale, formatOptions)}
|
||||
${energyUnit}
|
||||
${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}
|
||||
</td>
|
||||
${
|
||||
showCosts
|
||||
@@ -211,7 +172,7 @@ export class HuiEnergySourcesTableCard
|
||||
: ""
|
||||
}
|
||||
<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
|
||||
${formatNumber(energy, this.hass.locale, formatOptions)} ${energyUnit}
|
||||
${formatNumber(energy, this.hass.locale)} ${energyUnit}
|
||||
</td>
|
||||
${
|
||||
showCosts
|
||||
@@ -242,8 +203,7 @@ export class HuiEnergySourcesTableCard
|
||||
showCosts: boolean,
|
||||
compare: boolean,
|
||||
bulletColor?: { border: string; background: string },
|
||||
isFinalTotal?: boolean,
|
||||
formatOptions?: Intl.NumberFormatOptions
|
||||
isFinalTotal?: boolean
|
||||
) {
|
||||
return html` <tr
|
||||
class="mdc-data-table__row ${bulletColor && !isFinalTotal ? "" : "total"}"
|
||||
@@ -268,11 +228,7 @@ export class HuiEnergySourcesTableCard
|
||||
${
|
||||
compareEnergy === null
|
||||
? ""
|
||||
: `${formatNumber(
|
||||
compareEnergy,
|
||||
this.hass.locale,
|
||||
formatOptions
|
||||
)} ${energyUnit}`
|
||||
: `${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}`
|
||||
}
|
||||
</td>
|
||||
${
|
||||
@@ -297,11 +253,7 @@ export class HuiEnergySourcesTableCard
|
||||
${
|
||||
energy === null
|
||||
? ""
|
||||
: `${formatNumber(
|
||||
energy,
|
||||
this.hass.locale,
|
||||
formatOptions
|
||||
)} ${energyUnit}`
|
||||
: `${formatNumber(energy, this.hass.locale)} ${energyUnit}`
|
||||
}
|
||||
</td>
|
||||
${
|
||||
@@ -406,30 +358,6 @@ export class HuiEnergySourcesTableCard
|
||||
water: this._data.waterUnit,
|
||||
};
|
||||
|
||||
const gasDisplayPrecisions = types.gas
|
||||
?.filter(
|
||||
(source) =>
|
||||
this.hass.states[source.stat_energy_from]?.attributes
|
||||
.unit_of_measurement === units.gas
|
||||
)
|
||||
.map(
|
||||
(source) =>
|
||||
this.hass.entities[source.stat_energy_from]?.display_precision
|
||||
)
|
||||
.filter((precision): precision is number => precision !== undefined);
|
||||
|
||||
const gasDisplayPrecision = gasDisplayPrecisions?.length
|
||||
? Math.max(...gasDisplayPrecisions)
|
||||
: undefined;
|
||||
|
||||
const gasFormatOptions =
|
||||
gasDisplayPrecision !== undefined
|
||||
? {
|
||||
minimumFractionDigits: gasDisplayPrecision,
|
||||
maximumFractionDigits: gasDisplayPrecision,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const compare = this._data.statsCompare !== undefined;
|
||||
|
||||
const _extractStatData = (
|
||||
@@ -553,9 +481,7 @@ export class HuiEnergySourcesTableCard
|
||||
0
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
false,
|
||||
type === "gas" ? gasFormatOptions : undefined
|
||||
: undefined
|
||||
)
|
||||
: ""
|
||||
}`;
|
||||
|
||||
@@ -222,9 +222,6 @@ export class HuiCalendarCard
|
||||
"has-title": !!this._config.title,
|
||||
loading: loading,
|
||||
})}
|
||||
?add-fab=${this._config.show_add_event}
|
||||
add-fab-style=${this._config.show_add_event ? (this._config.add_event_style ?? "below") : nothing}
|
||||
add-fab-size=${this._config.show_add_event && this._config.add_event_style !== "header" ? (this._config.add_event_size ?? "small") : nothing}
|
||||
.narrow=${this._narrow}
|
||||
.events=${this._events}
|
||||
.calendars=${this._calendars}
|
||||
@@ -373,7 +370,11 @@ export class HuiCalendarCard
|
||||
}
|
||||
|
||||
private _measureCard() {
|
||||
this._narrow = this.offsetWidth < 870;
|
||||
const card = this.shadowRoot!.querySelector("ha-card");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
this._narrow = card.offsetWidth < 870;
|
||||
}
|
||||
|
||||
private async _attachObserver(): Promise<void> {
|
||||
@@ -382,7 +383,12 @@ export class HuiCalendarCard
|
||||
debounce(() => this._measureCard(), 250, false)
|
||||
);
|
||||
}
|
||||
this._resizeObserver.observe(this);
|
||||
const card = this.shadowRoot!.querySelector("ha-card");
|
||||
// If we show an error or warning there is no ha-card
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
this._resizeObserver.observe(card);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -408,9 +414,9 @@ export class HuiCalendarCard
|
||||
|
||||
ha-full-calendar {
|
||||
--calendar-height: 400px;
|
||||
display: flex;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: var(--calendar-height);
|
||||
min-height: var(--calendar-height);
|
||||
}
|
||||
|
||||
@@ -425,14 +431,9 @@ export class HuiCalendarCard
|
||||
|
||||
ha-full-calendar.is-grid.has-title,
|
||||
ha-full-calendar.is-panel.has-title {
|
||||
--header-height: calc(
|
||||
var(--ha-card-header-font-size, var(--ha-font-size-2xl)) *
|
||||
var(--ha-line-height-condensed) + 16px
|
||||
);
|
||||
--calendar-height: calc(
|
||||
100% - var(--ha-card-header-font-size, var(--ha-font-size-2xl)) - 22px
|
||||
);
|
||||
height: calc(100% - var(--header-height));
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -52,9 +52,6 @@ export interface CalendarCardConfig extends LovelaceCardConfig {
|
||||
initial_view?: FullCalendarView;
|
||||
title?: string;
|
||||
theme?: string;
|
||||
show_add_event?: boolean;
|
||||
add_event_style?: "below" | "on_top" | "header";
|
||||
add_event_size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
export interface ConditionalCardConfig extends LovelaceCardConfig {
|
||||
|
||||
@@ -10,16 +10,12 @@ import {
|
||||
optional,
|
||||
string,
|
||||
union,
|
||||
literal,
|
||||
} from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/entity/ha-entities-picker";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { CalendarCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
@@ -31,13 +27,6 @@ const cardConfigStruct = assign(
|
||||
title: optional(union([string(), boolean()])),
|
||||
initial_view: optional(string()),
|
||||
theme: optional(string()),
|
||||
show_add_event: optional(boolean()),
|
||||
add_event_style: optional(
|
||||
union([literal("below"), literal("on_top"), literal("header")])
|
||||
),
|
||||
add_event_size: optional(
|
||||
union([literal("small"), literal("medium"), literal("large")])
|
||||
),
|
||||
entities: array(string()),
|
||||
})
|
||||
);
|
||||
@@ -83,83 +72,7 @@ export class HuiCalendarCardEditor
|
||||
],
|
||||
},
|
||||
{ name: "theme", required: false, selector: { theme: {} } },
|
||||
{ name: "show_add_event", required: false, selector: { boolean: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "add_event_style",
|
||||
default: "below",
|
||||
visible: { field: "show_add_event", operator: "eq", value: true },
|
||||
required: false,
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{
|
||||
value: "below",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.style.below"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "on_top",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.style.on_top"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "header",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.style.header"
|
||||
),
|
||||
},
|
||||
],
|
||||
mode: "dropdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add_event_size",
|
||||
default: "small",
|
||||
visible: [
|
||||
{ field: "show_add_event", operator: "eq", value: true },
|
||||
{
|
||||
field: "add_event_style",
|
||||
operator: "not_eq",
|
||||
value: "header",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{
|
||||
value: "small",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.size.s"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "medium",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.size.m"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "large",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.size.l"
|
||||
),
|
||||
},
|
||||
],
|
||||
mode: "dropdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
] as const
|
||||
);
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type {
|
||||
PictureEntityCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../cards/types";
|
||||
|
||||
export const computeFavoriteCardConfig = (
|
||||
entityId: string
|
||||
): LovelaceCardConfig => {
|
||||
// A camera tile would only show a thumbnail, so give the picture the room.
|
||||
if (computeDomain(entityId) === "camera") {
|
||||
return {
|
||||
type: "picture-entity",
|
||||
entity: entityId,
|
||||
show_name: false,
|
||||
show_state: false,
|
||||
grid_options: {
|
||||
columns: 6,
|
||||
rows: 2,
|
||||
},
|
||||
} satisfies PictureEntityCardConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
// Favorites come from all over the home, so name the area.
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
} satisfies TileCardConfig;
|
||||
};
|
||||
@@ -34,7 +34,6 @@ import type {
|
||||
TileCardConfig,
|
||||
UpdatesCardConfig,
|
||||
} from "../../cards/types";
|
||||
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
|
||||
import {
|
||||
LARGE_SCREEN_CONDITION,
|
||||
SMALL_SCREEN_CONDITION,
|
||||
@@ -272,7 +271,15 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
column_span: maxColumns,
|
||||
cards: [
|
||||
favoritesHeadingCard,
|
||||
...favoriteEntities.map(computeFavoriteCardConfig),
|
||||
...favoriteEntities.map(
|
||||
(entityId) =>
|
||||
({
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
}) satisfies TileCardConfig
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+10
-6
@@ -4,9 +4,8 @@ import { isComponentLoaded } from "../../../../common/config/is_component_loaded
|
||||
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
|
||||
import { getCommonControlsUsagePrediction } from "../../../../data/usage_prediction";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { HeadingCardConfig } from "../../cards/types";
|
||||
import type { HeadingCardConfig, TileCardConfig } from "../../cards/types";
|
||||
import type { Condition } from "../../common/validate-condition";
|
||||
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
|
||||
import type { LovelaceStrategyDependency } from "../types";
|
||||
|
||||
const DEFAULT_LIMIT = 8;
|
||||
@@ -26,6 +25,13 @@ export interface CommonControlsSectionStrategyConfig {
|
||||
title_visibilty?: Condition[];
|
||||
}
|
||||
|
||||
const toTileCard = (entity: string): TileCardConfig => ({
|
||||
type: "tile",
|
||||
entity,
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
});
|
||||
|
||||
@customElement("common-controls-section-strategy")
|
||||
export class CommonControlsSectionStrategy extends ReactiveElement {
|
||||
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
|
||||
@@ -57,9 +63,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
|
||||
|
||||
// Pinned entities already fill the section, skip the prediction call.
|
||||
if (includedEntities.length >= limit) {
|
||||
section.cards!.push(
|
||||
...includedEntities.slice(0, limit).map(computeFavoriteCardConfig)
|
||||
);
|
||||
section.cards!.push(...includedEntities.slice(0, limit).map(toTileCard));
|
||||
return section;
|
||||
}
|
||||
|
||||
@@ -106,7 +110,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
|
||||
return section;
|
||||
}
|
||||
|
||||
section.cards!.push(...entities.map(computeFavoriteCardConfig));
|
||||
section.cards!.push(...entities.map(toTileCard));
|
||||
return section;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ import type { HomeAssistant } from "../../../types";
|
||||
import type { TileCardConfig } from "../../lovelace/cards/types";
|
||||
import { BINARY_STATE_ON } from "../../../common/const";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
|
||||
import { findBatteryChargingEntity } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
type EntityRegistryDisplayEntry,
|
||||
findBatteryChargingEntity,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
|
||||
export interface MaintenanceViewStrategyConfig {
|
||||
type: "maintenance";
|
||||
@@ -35,42 +37,44 @@ export const maintenanceEntityFilters: EntityFilter[] = [
|
||||
|
||||
const LOW_BATTERY_THRESHOLD = 20;
|
||||
|
||||
const _deviceEntityLookup = memoizeOne((entities: HomeAssistant["entities"]) =>
|
||||
getDeviceEntityDisplayLookup(Object.values(entities))
|
||||
const _deviceEntities = memoizeOne(
|
||||
(
|
||||
deviceId: string,
|
||||
entities: HomeAssistant["entities"]
|
||||
): EntityRegistryDisplayEntry[] => {
|
||||
const entries = Object.values(entities);
|
||||
return entries.filter((entity) => entity.device_id === deviceId);
|
||||
}
|
||||
);
|
||||
|
||||
export const filterLowBatteryEntities = (
|
||||
hass: HomeAssistant,
|
||||
entityIds: string[]
|
||||
): string[] => {
|
||||
return entityIds.filter((entityId) => {
|
||||
): string[] =>
|
||||
entityIds.filter((entityId) => {
|
||||
const state = hass.states[entityId]?.state ?? "";
|
||||
|
||||
if (computeDomain(entityId) === "binary_sensor") {
|
||||
return state === BINARY_STATE_ON;
|
||||
}
|
||||
|
||||
const stateValue = parseFloat(state);
|
||||
if (isNaN(stateValue) || stateValue > LOW_BATTERY_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deviceId = hass.entities[entityId]?.device_id;
|
||||
if (!deviceId) {
|
||||
return true;
|
||||
}
|
||||
const entities = deviceId ? _deviceEntities(deviceId, hass.entities) : [];
|
||||
|
||||
const batteryChargingEntity = findBatteryChargingEntity(
|
||||
hass.states,
|
||||
_deviceEntityLookup(hass.entities)[deviceId] ?? []
|
||||
entities
|
||||
);
|
||||
const batteryCharging = batteryChargingEntity
|
||||
? hass.states[batteryChargingEntity.entity_id]
|
||||
? hass.states[batteryChargingEntity?.entity_id]
|
||||
: undefined;
|
||||
|
||||
return batteryCharging?.state !== "on";
|
||||
if (batteryCharging && batteryCharging.state === "on") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stateValue = parseFloat(state);
|
||||
return !isNaN(stateValue) && stateValue <= LOW_BATTERY_THRESHOLD;
|
||||
});
|
||||
};
|
||||
|
||||
export const filterUnavailableBatteryEntities = (
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -18,9 +18,11 @@ import type {
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LogbookCardConfig } from "../../lovelace/cards/types";
|
||||
import type {
|
||||
LogbookCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../lovelace/cards/types";
|
||||
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
|
||||
import { computeSecurityAlertCardConfig } from "./security-alerts";
|
||||
|
||||
export interface SecurityViewStrategyConfig {
|
||||
@@ -184,7 +186,15 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...favoriteEntities.map(computeFavoriteCardConfig),
|
||||
...favoriteEntities.map(
|
||||
(entityId) =>
|
||||
({
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
}) satisfies TileCardConfig
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9924,21 +9924,6 @@
|
||||
"dayGridWeek": "Week",
|
||||
"dayGridDay": "Day",
|
||||
"listWeek": "List (7 days)"
|
||||
},
|
||||
"show_add_event": "Show add event button",
|
||||
"add_event_style": "Add event button style",
|
||||
"add_event_size": "Add event button size",
|
||||
"add_event": {
|
||||
"style": {
|
||||
"header": "Header",
|
||||
"below": "Below",
|
||||
"on_top": "On top"
|
||||
},
|
||||
"size": {
|
||||
"s": "Small",
|
||||
"m": "Medium",
|
||||
"l": "Large"
|
||||
}
|
||||
}
|
||||
},
|
||||
"conditional": {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addLatinLabels } from "../../build-scripts/gulp/map-labels.js";
|
||||
|
||||
// The rewrite keys on the exact `text-field` @versatiles/style emits; a bump
|
||||
// that changed it would silently ship local-only names again.
|
||||
|
||||
const layer = (id, layout) => ({ id, type: "symbol", layout });
|
||||
|
||||
const STYLE = {
|
||||
layers: [
|
||||
layer("label-place-city", { "text-field": ["get", "name"] }),
|
||||
layer("label-street-primary", {
|
||||
"symbol-placement": "line",
|
||||
"text-field": ["get", "name"],
|
||||
}),
|
||||
layer("label-motorway-shield", { "text-field": "{ref}" }),
|
||||
{ id: "water", type: "fill" },
|
||||
],
|
||||
};
|
||||
|
||||
// Just enough of the expression language for the expressions built here.
|
||||
const evaluate = (expression, properties) => {
|
||||
if (!Array.isArray(expression)) {
|
||||
return expression;
|
||||
}
|
||||
const [op, ...args] = expression;
|
||||
switch (op) {
|
||||
case "get":
|
||||
return properties[args[0]];
|
||||
case "has":
|
||||
return args[0] in properties;
|
||||
case "!":
|
||||
return !evaluate(args[0], properties);
|
||||
case "<":
|
||||
return evaluate(args[0], properties) < evaluate(args[1], properties);
|
||||
case "concat":
|
||||
return args.map((arg) => evaluate(arg, properties)).join("");
|
||||
case "format":
|
||||
return args
|
||||
.filter((_, i) => i % 2 === 0)
|
||||
.map((arg) => evaluate(arg, properties))
|
||||
.join("");
|
||||
case "case":
|
||||
for (let i = 0; i < args.length - 1; i += 2) {
|
||||
if (evaluate(args[i], properties)) {
|
||||
return evaluate(args[i + 1], properties);
|
||||
}
|
||||
}
|
||||
return evaluate(args[args.length - 1], properties);
|
||||
default:
|
||||
throw new Error(`Unexpected operator ${op}`);
|
||||
}
|
||||
};
|
||||
|
||||
const textField = (style, id) =>
|
||||
style.layers.find((l) => l.id === id).layout["text-field"];
|
||||
|
||||
describe("addLatinLabels", () => {
|
||||
const style = addLatinLabels(STYLE);
|
||||
const city = textField(style, "label-place-city");
|
||||
const street = textField(style, "label-street-primary");
|
||||
|
||||
it("leaves Latin names alone", () => {
|
||||
expect(evaluate(city, { name: "Köln", name_en: "Cologne" })).toBe("Köln");
|
||||
expect(evaluate(city, { name: "1er arrondissement" })).toBe(
|
||||
"1er arrondissement"
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the English name under a non-Latin one", () => {
|
||||
expect(evaluate(city, { name: "Москва", name_en: "Moscow" })).toBe(
|
||||
"Москва\nMoscow"
|
||||
);
|
||||
expect(evaluate(city, { name: "بيروت", name_en: "Beirut" })).toBe(
|
||||
"بيروت\nBeirut"
|
||||
);
|
||||
expect(evaluate(city, { name: "東京", name_en: "Tokyo" })).toBe(
|
||||
"東京\nTokyo"
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the English line smaller", () => {
|
||||
expect(city.at(-1)).toEqual(
|
||||
expect.arrayContaining([["get", "name_en"], { "font-scale": 0.8 }])
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the local name without an English one", () => {
|
||||
expect(evaluate(city, { name: "בני ברק" })).toBe("בני ברק");
|
||||
});
|
||||
|
||||
it("keeps street labels on one line", () => {
|
||||
expect(
|
||||
evaluate(street, { name: "شارع الحمرا", name_en: "Hamra Street" })
|
||||
).toBe("شارع الحمرا (Hamra Street)");
|
||||
});
|
||||
|
||||
it("does not touch other layers", () => {
|
||||
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
|
||||
expect(style.layers[3]).toEqual(STYLE.layers[3]);
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,21 @@ const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
|
||||
|
||||
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
|
||||
|
||||
const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin }));
|
||||
// The token module is driven directly here, so a stale token and the one that
|
||||
// replaces it can be played out without a WebSocket.
|
||||
const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>());
|
||||
const refreshMapTilesToken = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../src/data/map_tiles", () => ({
|
||||
MAP_TILES_PATH: "/api/map_tiles",
|
||||
refreshMapTilesToken,
|
||||
subscribeMapTilesToken: (listener: (token: string) => void) => {
|
||||
tokenListeners.add(listener);
|
||||
return () => tokenListeners.delete(listener);
|
||||
},
|
||||
withMapTilesToken: (url: string) => new URL(url, location.href).href,
|
||||
}));
|
||||
const emitToken = (token: string) =>
|
||||
tokenListeners.forEach((listener) => listener(token));
|
||||
|
||||
const STYLE = {
|
||||
version: 8,
|
||||
@@ -29,19 +41,23 @@ const STYLE = {
|
||||
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 rasterLayer = {
|
||||
// Leaflet returns the layer from addTo, and the source chains off it.
|
||||
addTo: vi.fn(() => rasterLayer),
|
||||
redraw: vi.fn(),
|
||||
options: {} as Record<string, unknown>,
|
||||
};
|
||||
const leaflet = {
|
||||
tileLayer: vi.fn(() => rasterLayer),
|
||||
Browser: browser,
|
||||
} as unknown as LeafletModuleType;
|
||||
|
||||
const TOKEN = "test-token";
|
||||
|
||||
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
|
||||
const glHandlers: Record<string, () => void> = {};
|
||||
const glHandlers: Record<string, (event?: unknown) => void> = {};
|
||||
const glMap = {
|
||||
setStyle: vi.fn(),
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
on: vi.fn((event: string, handler: (event?: unknown) => void) => {
|
||||
glHandlers[event] = handler;
|
||||
}),
|
||||
};
|
||||
@@ -61,6 +77,7 @@ const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
tokenListeners.clear();
|
||||
maplibreLayer.options = {};
|
||||
maplibreGL.mockReturnValue(maplibreLayer);
|
||||
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
|
||||
@@ -83,52 +100,28 @@ describe("createBaseLayer", () => {
|
||||
it("falls back to raster tiles without WebGL2", async () => {
|
||||
const createBaseLayer = await setWebGL2(false);
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
expect(maplibreGL).not.toHaveBeenCalled();
|
||||
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
|
||||
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
|
||||
// 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");
|
||||
// Through core's proxy, which is what identifies Home Assistant upstream -
|
||||
// a browser can set neither a User-Agent nor a Referer.
|
||||
expect(url).toContain("/api/map_tiles/raster/{z}/{x}/{y}.png");
|
||||
// Leaflet substitutes options into the template on every tile request, so
|
||||
// the token can be refreshed without recreating the layer.
|
||||
expect(url).toContain("token={token}");
|
||||
expect(options).toMatchObject({ token: TOKEN });
|
||||
// The vector layer takes its credit from the style's source instead, so the
|
||||
// raster layer is the only one carrying attribution itself - and it credits
|
||||
// both the data and whoever rendered it.
|
||||
// raster layer is the only one carrying attribution itself.
|
||||
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("registers the RTL text plugin once, lazily, from our own host", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
|
||||
expect(setRTLTextPlugin).toHaveBeenCalledWith(
|
||||
`${location.origin}/static/map/mapbox-gl-rtl-text.js`,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("uses vector tiles when WebGL2 is available", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
expect(maplibreGL).toHaveBeenCalledOnce();
|
||||
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
|
||||
@@ -144,7 +137,7 @@ describe("createBaseLayer", () => {
|
||||
})
|
||||
);
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
});
|
||||
@@ -157,7 +150,7 @@ describe("createBaseLayer", () => {
|
||||
throw new Error("Failed to initialize WebGL");
|
||||
});
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
expect(maplibreLayer.remove).toHaveBeenCalled();
|
||||
@@ -172,7 +165,7 @@ describe("createBaseLayer", () => {
|
||||
throw new Error("nothing to remove");
|
||||
});
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
});
|
||||
@@ -185,7 +178,7 @@ describe("setDarkMode", () => {
|
||||
|
||||
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);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
baseLayer.setDarkMode(true);
|
||||
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
|
||||
@@ -199,7 +192,7 @@ describe("setDarkMode", () => {
|
||||
// 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 baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
const failing = vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
@@ -246,7 +239,7 @@ describe("setDarkMode", () => {
|
||||
|
||||
// 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);
|
||||
const pending = createBaseLayer(leaflet, map, false, TOKEN);
|
||||
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
|
||||
resolvers.shift()!(styleResponse("light"));
|
||||
const baseLayer = await pending;
|
||||
@@ -278,7 +271,7 @@ describe("WebGL context loss", () => {
|
||||
|
||||
it("falls back to raster tiles when the context stays lost", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
expect(leaflet.tileLayer).not.toHaveBeenCalled();
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
@@ -290,7 +283,7 @@ describe("WebGL context loss", () => {
|
||||
|
||||
it("keeps the vector layer when the context comes back", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
glHandlers.webglcontextrestored();
|
||||
@@ -306,7 +299,7 @@ describe("WebGL context loss", () => {
|
||||
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);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
@@ -322,7 +315,7 @@ describe("WebGL context loss", () => {
|
||||
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);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
glHandlers.webglcontextrestored();
|
||||
@@ -337,7 +330,7 @@ describe("WebGL context loss", () => {
|
||||
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);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
@@ -354,7 +347,7 @@ describe("WebGL context loss", () => {
|
||||
|
||||
it("stops answering theme changes once it has fallen back", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
@@ -363,3 +356,63 @@ describe("WebGL context loss", () => {
|
||||
expect(glMap.setStyle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// A refused request leaves the source dead: the TileJSON is fetched once and
|
||||
// MapLibre never retries it, so the map stays blank until the style is
|
||||
// applied again.
|
||||
describe("recovering from a refused token", () => {
|
||||
it("asks for a new token and re-applies the style once it arrives", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
glMap.setStyle.mockClear();
|
||||
|
||||
glHandlers.error({ error: { status: 403 } });
|
||||
expect(refreshMapTilesToken).toHaveBeenCalled();
|
||||
|
||||
emitToken("fresh-token");
|
||||
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("does not keep asking while the proxy refuses for another reason", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
glHandlers.error({ error: { status: 403 } });
|
||||
}
|
||||
|
||||
expect(refreshMapTilesToken).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores errors that are not a refusal", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.error({ error: { status: 500 } });
|
||||
|
||||
expect(refreshMapTilesToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a working map alone when the token is merely refreshed", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
glMap.setStyle.mockClear();
|
||||
|
||||
emitToken("fresh-token");
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
expect(glMap.setStyle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redraws the raster layer so refused tiles are asked for again", async () => {
|
||||
const createBaseLayer = await setWebGL2(false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
emitToken("fresh-token");
|
||||
|
||||
expect(rasterLayer.options.token).toBe("fresh-token");
|
||||
expect(rasterLayer.redraw).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeInitialHaFormData } from "../../../src/components/ha-form/compute-initial-ha-form-data";
|
||||
import type { Selector } from "../../../src/data/selector";
|
||||
import type { HaFormSchema } from "../../../src/components/ha-form/types";
|
||||
|
||||
const requiredField = (selector: Selector): HaFormSchema => ({
|
||||
name: "value",
|
||||
required: true,
|
||||
selector,
|
||||
});
|
||||
|
||||
const optionalField = (selector: Selector): HaFormSchema => ({
|
||||
name: "value",
|
||||
required: false,
|
||||
selector,
|
||||
});
|
||||
|
||||
describe("computeInitialHaFormData", () => {
|
||||
it("initializes required text selector with an empty string", () => {
|
||||
expect(computeInitialHaFormData([requiredField({ text: {} })])).toEqual({
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes required multiple text selector with an empty array", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
text: {
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes required object selector with an empty string", () => {
|
||||
expect(computeInitialHaFormData([requiredField({ object: {} })])).toEqual({
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes required multiple object selector with an empty array", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
object: {
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a required single device class selector unset", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("initializes a required multiple device class selector with an empty array", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not initialize optional text selectors", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
optionalField({ text: {} }),
|
||||
{
|
||||
...optionalField({
|
||||
text: {
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
name: "multiple",
|
||||
},
|
||||
])
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("does not initialize optional object selectors", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
optionalField({ object: {} }),
|
||||
{
|
||||
...optionalField({
|
||||
object: {
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
name: "multiple",
|
||||
},
|
||||
])
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("initializes a required constant selector without losing falsy values", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
constant: {
|
||||
value: false,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
constant: {
|
||||
value: 0,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes a required choose selector from a constant first choice", () => {
|
||||
const schema = [
|
||||
{
|
||||
name: "match",
|
||||
required: true,
|
||||
selector: {
|
||||
choose: {
|
||||
choices: {
|
||||
Disabled: {
|
||||
selector: {
|
||||
constant: {
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Enabled: {
|
||||
selector: {
|
||||
number: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(computeInitialHaFormData(schema)).toEqual({
|
||||
match: {
|
||||
active_choice: "Disabled",
|
||||
Disabled: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes a required choose selector from its child selector", () => {
|
||||
const schema = [
|
||||
{
|
||||
name: "mode",
|
||||
required: true,
|
||||
selector: {
|
||||
choose: {
|
||||
choices: {
|
||||
First: {
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Second: {
|
||||
selector: {
|
||||
text: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(computeInitialHaFormData(schema)).toEqual({
|
||||
mode: {
|
||||
active_choice: "First",
|
||||
First: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws for an unsupported required selector", () => {
|
||||
expect(() =>
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
ui_action: {
|
||||
default_action: "none",
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toThrow("Selector ui_action not supported in initial form data");
|
||||
});
|
||||
|
||||
it("omits a first choose child without an initial value", () => {
|
||||
const schema = [
|
||||
{
|
||||
name: "mode",
|
||||
required: true,
|
||||
selector: {
|
||||
choose: {
|
||||
choices: {
|
||||
First: {
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
Second: {
|
||||
selector: {
|
||||
text: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(computeInitialHaFormData(schema)).toStrictEqual({
|
||||
mode: {
|
||||
active_choice: "First",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ObjectSelector } from "../../../src/data/selector";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
|
||||
import type {
|
||||
FormDialogData,
|
||||
FormDialogParams,
|
||||
} from "../../../src/dialogs/form/show-form-dialog";
|
||||
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
|
||||
import "../../../src/components/ha-selector/ha-selector-object";
|
||||
|
||||
vi.mock("../../../src/components/ha-input-helper-text", () => {
|
||||
@@ -55,23 +51,12 @@ const selectorConfig = {
|
||||
const getInternals = (selector: HaObjectSelector) =>
|
||||
selector as unknown as Record<string, unknown>;
|
||||
|
||||
interface ItemActionEvent {
|
||||
stopPropagation: () => void;
|
||||
currentTarget: {
|
||||
item?: FormDialogData;
|
||||
index?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const mountSelector = async (
|
||||
value: FormDialogData | FormDialogData[] | "",
|
||||
config: ObjectSelector = selectorConfig
|
||||
) => {
|
||||
const mountSelector = async (value: Record<string, string>[]) => {
|
||||
const selector = document.createElement(
|
||||
"ha-selector-object"
|
||||
) as HaObjectSelector;
|
||||
selector.hass = hass;
|
||||
selector.selector = config;
|
||||
selector.selector = selectorConfig;
|
||||
selector.value = value;
|
||||
document.body.append(selector);
|
||||
await selector.updateComplete;
|
||||
@@ -81,8 +66,8 @@ const mountSelector = async (
|
||||
const resolveFormDialog = async (
|
||||
selector: HaObjectSelector,
|
||||
action: "_addItem" | "_editItem",
|
||||
result: FormDialogData | null,
|
||||
item?: FormDialogData,
|
||||
result: Record<string, string> | null,
|
||||
item?: Record<string, string>,
|
||||
index?: number
|
||||
) => {
|
||||
let params: FormDialogParams | undefined;
|
||||
@@ -102,14 +87,15 @@ const resolveFormDialog = async (
|
||||
);
|
||||
});
|
||||
|
||||
const event: ItemActionEvent = {
|
||||
const event = {
|
||||
stopPropagation: vi.fn(),
|
||||
currentTarget: { item, index },
|
||||
};
|
||||
const operation = (
|
||||
getInternals(selector)[action] as (event: ItemActionEvent) => Promise<void>
|
||||
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
|
||||
)(event);
|
||||
await Promise.all([dialogShown, operation]);
|
||||
await dialogShown;
|
||||
await operation;
|
||||
return params!;
|
||||
};
|
||||
|
||||
@@ -119,105 +105,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ha-selector-object form dialog flow", () => {
|
||||
it("initializes Add dialog data from the object field schema", async () => {
|
||||
const selector = await mountSelector([], {
|
||||
object: {
|
||||
multiple: true,
|
||||
fields: {
|
||||
name: {
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
},
|
||||
states: {
|
||||
required: true,
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const params = await resolveFormDialog(selector, "_addItem", {
|
||||
name: "A",
|
||||
states: ["on"],
|
||||
});
|
||||
|
||||
expect(params.data).toEqual({
|
||||
name: "",
|
||||
states: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unsupported required fields unset in Add dialog data", async () => {
|
||||
const selector = await mountSelector([], {
|
||||
object: {
|
||||
multiple: true,
|
||||
fields: {
|
||||
name: {
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
},
|
||||
tap_action: {
|
||||
required: true,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const params = await resolveFormDialog(selector, "_addItem", {
|
||||
name: "A",
|
||||
tap_action: {
|
||||
action: "none",
|
||||
},
|
||||
});
|
||||
|
||||
expect(params.data).toEqual({
|
||||
name: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the initialized empty value after an add-delete round trip", async () => {
|
||||
const selector = await mountSelector("", {
|
||||
object: {
|
||||
fields: {
|
||||
name: {
|
||||
selector: { text: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const valueChanged = vi.fn();
|
||||
selector.addEventListener("value-changed", valueChanged);
|
||||
|
||||
await resolveFormDialog(selector, "_addItem", { name: "A" });
|
||||
|
||||
expect(valueChanged.mock.calls[0][0].detail.value).toEqual({ name: "A" });
|
||||
|
||||
selector.value = valueChanged.mock.calls[0][0].detail.value;
|
||||
await selector.updateComplete;
|
||||
|
||||
const event: ItemActionEvent = {
|
||||
stopPropagation: vi.fn(),
|
||||
currentTarget: {
|
||||
index: 0,
|
||||
},
|
||||
};
|
||||
|
||||
(getInternals(selector)._deleteItem as (event: ItemActionEvent) => void)(
|
||||
event
|
||||
);
|
||||
|
||||
expect(valueChanged.mock.calls[1][0].detail.value).toBe("");
|
||||
});
|
||||
|
||||
it("appends an item through the real Add flow", async () => {
|
||||
const first = { name: "A" };
|
||||
const selector = await mountSelector([first]);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// MapLibre hands tile URLs to a worker, which has no document to resolve a
|
||||
// relative URL against. Measured: without absolute URLs nothing loads at all -
|
||||
// not the TileJSON, not the glyphs, not a tile - and MapLibre reports no error,
|
||||
// so this is worth pinning down.
|
||||
|
||||
const connectionWith = (...tokens: string[]) => {
|
||||
let call = 0;
|
||||
const listeners: Record<string, () => void> = {};
|
||||
return {
|
||||
sendMessagePromise: vi.fn(async () => ({
|
||||
token: tokens[Math.min(call++, tokens.length - 1)],
|
||||
})),
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event] = cb;
|
||||
}),
|
||||
listeners,
|
||||
} as unknown as Connection & { listeners: Record<string, () => void> };
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
vi.resetModules();
|
||||
return import("../../src/data/map_tiles");
|
||||
};
|
||||
|
||||
describe("withMapTilesToken", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("makes relative URLs absolute", async () => {
|
||||
const { withMapTilesToken } = await load();
|
||||
|
||||
expect(withMapTilesToken("/api/map_tiles/tilejson.json")).toBe(
|
||||
`${location.origin}/api/map_tiles/tilejson.json`
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the token to proxy URLs once there is one", async () => {
|
||||
const { ensureMapTilesToken, withMapTilesToken } = await load();
|
||||
await ensureMapTilesToken(connectionWith("abc123"));
|
||||
|
||||
const url = new URL(withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt"));
|
||||
expect(url.searchParams.get("token")).toBe("abc123");
|
||||
expect(url.pathname).toBe("/api/map_tiles/vector/1/0/0.mvt");
|
||||
});
|
||||
|
||||
it("leaves anything outside the proxy alone", async () => {
|
||||
const { ensureMapTilesToken, withMapTilesToken } = await load();
|
||||
await ensureMapTilesToken(connectionWith("abc123"));
|
||||
|
||||
const url = new URL(
|
||||
withMapTilesToken("https://example.com/tiles/1/0/0.png")
|
||||
);
|
||||
expect(url.searchParams.get("token")).toBeNull();
|
||||
expect(url.href).toBe("https://example.com/tiles/1/0/0.png");
|
||||
});
|
||||
|
||||
it("still returns an absolute URL when there is no token", async () => {
|
||||
const { withMapTilesToken } = await load();
|
||||
|
||||
expect(
|
||||
withMapTilesToken("/api/map_tiles/fonts/noto_sans_regular/0-255.pbf")
|
||||
).toBe(
|
||||
`${location.origin}/api/map_tiles/fonts/noto_sans_regular/0-255.pbf`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureMapTilesToken", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("asks the backend once and reuses the answer", async () => {
|
||||
const { ensureMapTilesToken } = await load();
|
||||
const connection = connectionWith("abc123");
|
||||
|
||||
expect(await ensureMapTilesToken(connection)).toBe("abc123");
|
||||
expect(await ensureMapTilesToken(connection)).toBe("abc123");
|
||||
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A backend without the proxy must not hold the map hostage while the
|
||||
// retries run; the map falls back to loading nothing rather than to nothing
|
||||
// being drawn at all.
|
||||
it("gives up quickly on a backend that does not answer", async () => {
|
||||
const { ensureMapTilesToken } = await load();
|
||||
const connection = {
|
||||
sendMessagePromise: vi.fn(async () => {
|
||||
throw new Error("unknown command");
|
||||
}),
|
||||
} as unknown as Connection;
|
||||
|
||||
const started = Date.now();
|
||||
expect(await ensureMapTilesToken(connection)).toBeUndefined();
|
||||
expect(Date.now() - started).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
it("tells subscribers when a token arrives, for Leaflet's baked template", async () => {
|
||||
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
|
||||
const listener = vi.fn();
|
||||
subscribeMapTilesToken(listener);
|
||||
|
||||
await ensureMapTilesToken(connectionWith("abc123"));
|
||||
|
||||
expect(listener).toHaveBeenCalledWith("abc123");
|
||||
});
|
||||
});
|
||||
|
||||
// A token can expire before the refresh interval comes round, so recovery
|
||||
// hangs on the reconnect and on retrying a refused request.
|
||||
describe("recovering a stale token", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("fetches a new token when the connection comes back", async () => {
|
||||
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
|
||||
const connection = connectionWith("first", "second") as Connection & {
|
||||
listeners: Record<string, () => void>;
|
||||
};
|
||||
await ensureMapTilesToken(connection);
|
||||
|
||||
const listener = vi.fn();
|
||||
subscribeMapTilesToken(listener);
|
||||
connection.listeners.ready();
|
||||
await vi.waitFor(() => expect(listener).toHaveBeenCalledWith("second"));
|
||||
});
|
||||
|
||||
it("asks once for a refresh however many tiles were refused", async () => {
|
||||
const { ensureMapTilesToken, refreshMapTilesToken } = await load();
|
||||
const connection = connectionWith("first", "second");
|
||||
await ensureMapTilesToken(connection);
|
||||
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
|
||||
|
||||
await Promise.all([
|
||||
refreshMapTilesToken(),
|
||||
refreshMapTilesToken(),
|
||||
refreshMapTilesToken(),
|
||||
]);
|
||||
|
||||
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the old token when the refresh fails", async () => {
|
||||
const { ensureMapTilesToken, refreshMapTilesToken, withMapTilesToken } =
|
||||
await load();
|
||||
const connection = connectionWith("first");
|
||||
await ensureMapTilesToken(connection);
|
||||
|
||||
vi.mocked(connection.sendMessagePromise).mockRejectedValueOnce(
|
||||
new Error("disconnected")
|
||||
);
|
||||
await refreshMapTilesToken();
|
||||
|
||||
expect(
|
||||
new URL(
|
||||
withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt")
|
||||
).searchParams.get("token")
|
||||
).toBe("first");
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,7 @@ const commandResults: Record<string, unknown> = {
|
||||
},
|
||||
"lovelace/info": { resource_mode: "storage" },
|
||||
"lovelace/resources": [],
|
||||
"map_tiles/access_token": { token: "map-tiles-token" },
|
||||
"recorder/info": {
|
||||
migration_in_progress: false,
|
||||
migration_is_live: false,
|
||||
@@ -177,6 +178,13 @@ export async function setupOnboardingMocks(
|
||||
): Promise<OnboardingCalls> {
|
||||
const calls: OnboardingCalls = { tokenRequests: [] };
|
||||
|
||||
// The location step shows a map, which asks core's tile proxy for tiles core
|
||||
// is not running here. Answer them so the outcome does not depend on what the
|
||||
// dev server does with an unknown /api path.
|
||||
await page.route("**/api/map_tiles/**", (route) =>
|
||||
route.fulfill({ status: 404, body: "" })
|
||||
);
|
||||
|
||||
await page.route("**/api/onboarding**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createZHANetworkChartData } from "../../../../../../src/panels/config/integrations/integration-panels/zha/zha-network-data";
|
||||
import type { ZHADevice } from "../../../../../../src/data/zha";
|
||||
import type { HomeAssistant } from "../../../../../../src/types";
|
||||
|
||||
const hass = {
|
||||
devices: {},
|
||||
areas: {},
|
||||
localize: () => "",
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const device = (partial: Partial<ZHADevice>): ZHADevice =>
|
||||
({
|
||||
available: true,
|
||||
name: partial.ieee!,
|
||||
lqi: 0,
|
||||
rssi: "",
|
||||
last_seen: "",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
quirk_applied: false,
|
||||
quirk_class: "",
|
||||
entities: [],
|
||||
manufacturer_code: 0,
|
||||
device_reg_id: partial.ieee!,
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
routes: [],
|
||||
neighbors: [],
|
||||
...partial,
|
||||
}) as ZHADevice;
|
||||
|
||||
describe("createZHANetworkChartData", () => {
|
||||
it("links a router to its upstream router instead of a nearby child device when routing tables are empty", () => {
|
||||
// Regression test: many Zigbee radios (e.g. TI CC2652 via zigpy-znp)
|
||||
// never populate Mgmt_Rtg routes, so the chart has to fall back to
|
||||
// picking a device's strongest RF neighbor. A router's own child end
|
||||
// device (e.g. a plug sitting right next to it) commonly reports a
|
||||
// stronger LQI than the router's real uplink, which must not be
|
||||
// allowed to hide the backbone connection.
|
||||
const coordinator = device({
|
||||
ieee: "coordinator",
|
||||
device_type: "Coordinator",
|
||||
nwk: 0,
|
||||
});
|
||||
const upstreamRouter = device({
|
||||
ieee: "upstream-router",
|
||||
device_type: "Router",
|
||||
nwk: 1,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "coordinator",
|
||||
nwk: "0x0000",
|
||||
lqi: "200",
|
||||
depth: "0",
|
||||
relationship: "Parent",
|
||||
},
|
||||
],
|
||||
});
|
||||
const downstreamRouter = device({
|
||||
ieee: "downstream-router",
|
||||
device_type: "Router",
|
||||
nwk: 2,
|
||||
neighbors: [
|
||||
// Real backbone link: weaker signal, correctly flagged as a sibling router
|
||||
{
|
||||
ieee: "upstream-router",
|
||||
nwk: "0x0001",
|
||||
lqi: "80",
|
||||
depth: "1",
|
||||
relationship: "Sibling",
|
||||
},
|
||||
// Nearby child end device with a much stronger signal than the real uplink
|
||||
{
|
||||
ieee: "child-end-device",
|
||||
nwk: "0x0003",
|
||||
lqi: "250",
|
||||
depth: "2",
|
||||
relationship: "Child",
|
||||
},
|
||||
],
|
||||
});
|
||||
const childEndDevice = device({
|
||||
ieee: "child-end-device",
|
||||
device_type: "EndDevice",
|
||||
nwk: 3,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "downstream-router",
|
||||
nwk: "0x0002",
|
||||
lqi: "250",
|
||||
depth: "2",
|
||||
relationship: "Parent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { links } = createZHANetworkChartData(
|
||||
[coordinator, upstreamRouter, downstreamRouter, childEndDevice],
|
||||
hass,
|
||||
document.createElement("div")
|
||||
);
|
||||
|
||||
const adjacency = new Map<string, string[]>();
|
||||
for (const link of links) {
|
||||
adjacency.set(link.source, [
|
||||
...(adjacency.get(link.source) ?? []),
|
||||
link.target,
|
||||
]);
|
||||
adjacency.set(link.target, [
|
||||
...(adjacency.get(link.target) ?? []),
|
||||
link.source,
|
||||
]);
|
||||
}
|
||||
|
||||
// Every device should be reachable from the coordinator. Before the
|
||||
// fix, downstream-router picked its child as its only link, leaving
|
||||
// it (and its children) as a disconnected island in the chart even
|
||||
// though the real network is fully connected.
|
||||
const reachable = new Set(["coordinator"]);
|
||||
const queue = ["coordinator"];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
for (const neighbor of adjacency.get(current) ?? []) {
|
||||
if (!reachable.has(neighbor)) {
|
||||
reachable.add(neighbor);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(reachable.has("upstream-router")).toBe(true);
|
||||
expect(reachable.has("downstream-router")).toBe(true);
|
||||
expect(reachable.has("child-end-device")).toBe(true);
|
||||
});
|
||||
|
||||
it("ranks NoneOfTheAbove above Child, and treats unrecognized relationships as better than PreviousChild", () => {
|
||||
const router = device({
|
||||
ieee: "router",
|
||||
device_type: "Router",
|
||||
nwk: 1,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "n-child",
|
||||
nwk: "0x0001",
|
||||
lqi: "100",
|
||||
depth: "1",
|
||||
relationship: "Child",
|
||||
},
|
||||
{
|
||||
ieee: "n-none-of-the-above",
|
||||
nwk: "0x0002",
|
||||
lqi: "100",
|
||||
depth: "1",
|
||||
relationship: "NoneOfTheAbove",
|
||||
},
|
||||
],
|
||||
});
|
||||
const unknownRelationship = device({
|
||||
ieee: "router-2",
|
||||
device_type: "Router",
|
||||
nwk: 2,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "n-unrecognized",
|
||||
nwk: "0x0003",
|
||||
lqi: "100",
|
||||
depth: "1",
|
||||
relationship: "SomeFutureValue",
|
||||
},
|
||||
{
|
||||
ieee: "n-previous-child",
|
||||
nwk: "0x0004",
|
||||
lqi: "100",
|
||||
depth: "1",
|
||||
relationship: "PreviousChild",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { links } = createZHANetworkChartData(
|
||||
[router, unknownRelationship],
|
||||
hass,
|
||||
document.createElement("div")
|
||||
);
|
||||
|
||||
const otherEndOf = (ieee: string) => {
|
||||
const link = links.find((l) => l.source === ieee || l.target === ieee);
|
||||
return link?.source === ieee ? link.target : link?.source;
|
||||
};
|
||||
|
||||
expect(otherEndOf("router")).toBe("n-none-of-the-above");
|
||||
expect(otherEndOf("router-2")).toBe("n-unrecognized");
|
||||
});
|
||||
|
||||
it("connects every device regardless of backend device order (child listed before its parent router)", () => {
|
||||
// Regression test for a subtler variant of the same bug: the fallback
|
||||
// link is only computed for a device if it doesn't already have a
|
||||
// link. If the backend lists a child end device before its parent
|
||||
// router, the child claims the link first, and the router - now
|
||||
// appearing to "already have a link" - never gets to evaluate its own
|
||||
// (better) neighbor choice, splitting the graph exactly as before but
|
||||
// triggered by device order instead of by LQI.
|
||||
const coordinator = device({
|
||||
ieee: "coordinator",
|
||||
device_type: "Coordinator",
|
||||
nwk: 0,
|
||||
});
|
||||
const upstreamRouter = device({
|
||||
ieee: "upstream-router",
|
||||
device_type: "Router",
|
||||
nwk: 1,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "coordinator",
|
||||
nwk: "0x0000",
|
||||
lqi: "200",
|
||||
depth: "0",
|
||||
relationship: "Parent",
|
||||
},
|
||||
],
|
||||
});
|
||||
const downstreamRouter = device({
|
||||
ieee: "downstream-router",
|
||||
device_type: "Router",
|
||||
nwk: 2,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "upstream-router",
|
||||
nwk: "0x0001",
|
||||
lqi: "80",
|
||||
depth: "1",
|
||||
relationship: "Sibling",
|
||||
},
|
||||
{
|
||||
ieee: "child-end-device",
|
||||
nwk: "0x0003",
|
||||
lqi: "250",
|
||||
depth: "2",
|
||||
relationship: "Child",
|
||||
},
|
||||
],
|
||||
});
|
||||
const childEndDevice = device({
|
||||
ieee: "child-end-device",
|
||||
device_type: "EndDevice",
|
||||
nwk: 3,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "downstream-router",
|
||||
nwk: "0x0002",
|
||||
lqi: "250",
|
||||
depth: "2",
|
||||
relationship: "Parent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Same topology as the first test, but the child is listed before its
|
||||
// parent router this time.
|
||||
const { links } = createZHANetworkChartData(
|
||||
[coordinator, upstreamRouter, childEndDevice, downstreamRouter],
|
||||
hass,
|
||||
document.createElement("div")
|
||||
);
|
||||
|
||||
const adjacency = new Map<string, string[]>();
|
||||
for (const link of links) {
|
||||
adjacency.set(link.source, [
|
||||
...(adjacency.get(link.source) ?? []),
|
||||
link.target,
|
||||
]);
|
||||
adjacency.set(link.target, [
|
||||
...(adjacency.get(link.target) ?? []),
|
||||
link.source,
|
||||
]);
|
||||
}
|
||||
|
||||
const reachable = new Set(["coordinator"]);
|
||||
const queue = ["coordinator"];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
for (const neighbor of adjacency.get(current) ?? []) {
|
||||
if (!reachable.has(neighbor)) {
|
||||
reachable.add(neighbor);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(reachable.has("upstream-router")).toBe(true);
|
||||
expect(reachable.has("downstream-router")).toBe(true);
|
||||
expect(reachable.has("child-end-device")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterLowBatteryEntities } from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
|
||||
import { mockEntity } from "../../../common/entity/context/context-mock";
|
||||
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
|
||||
|
||||
describe("filterLowBatteryEntities", () => {
|
||||
it("filters numeric battery entities by the low battery threshold", () => {
|
||||
const hass = createMockHass({
|
||||
"sensor.low_battery": createMockEntityState("sensor.low_battery", "20", {
|
||||
device_class: "battery",
|
||||
}),
|
||||
"sensor.ok_battery": createMockEntityState("sensor.ok_battery", "21", {
|
||||
device_class: "battery",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
filterLowBatteryEntities(hass, [
|
||||
"sensor.low_battery",
|
||||
"sensor.ok_battery",
|
||||
])
|
||||
).toEqual(["sensor.low_battery"]);
|
||||
});
|
||||
|
||||
it("excludes a low battery when its device is charging", () => {
|
||||
const entities = {
|
||||
"sensor.device_1_battery": mockEntity({
|
||||
entity_id: "sensor.device_1_battery",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
"binary_sensor.device_1_battery_charging": mockEntity({
|
||||
entity_id: "binary_sensor.device_1_battery_charging",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
"sensor.device_2_battery": mockEntity({
|
||||
entity_id: "sensor.device_2_battery",
|
||||
device_id: "device_2",
|
||||
}),
|
||||
"binary_sensor.device_2_battery_charging": mockEntity({
|
||||
entity_id: "binary_sensor.device_2_battery_charging",
|
||||
device_id: "device_2",
|
||||
}),
|
||||
};
|
||||
|
||||
const hass = createMockHass(
|
||||
{
|
||||
"sensor.device_1_battery": createMockEntityState(
|
||||
"sensor.device_1_battery",
|
||||
"10",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
"binary_sensor.device_1_battery_charging": createMockEntityState(
|
||||
"binary_sensor.device_1_battery_charging",
|
||||
"on",
|
||||
{ device_class: "battery_charging" }
|
||||
),
|
||||
"sensor.device_2_battery": createMockEntityState(
|
||||
"sensor.device_2_battery",
|
||||
"10",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
"binary_sensor.device_2_battery_charging": createMockEntityState(
|
||||
"binary_sensor.device_2_battery_charging",
|
||||
"off",
|
||||
{ device_class: "battery_charging" }
|
||||
),
|
||||
},
|
||||
{ entities }
|
||||
);
|
||||
|
||||
expect(
|
||||
filterLowBatteryEntities(hass, [
|
||||
"sensor.device_1_battery",
|
||||
"sensor.device_2_battery",
|
||||
])
|
||||
).toEqual(["sensor.device_2_battery"]);
|
||||
});
|
||||
|
||||
it("keeps binary battery sensor behavior unchanged", () => {
|
||||
const hass = createMockHass({
|
||||
"binary_sensor.low_battery": createMockEntityState(
|
||||
"binary_sensor.low_battery",
|
||||
"on",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
"binary_sensor.ok_battery": createMockEntityState(
|
||||
"binary_sensor.ok_battery",
|
||||
"off",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
filterLowBatteryEntities(hass, [
|
||||
"binary_sensor.low_battery",
|
||||
"binary_sensor.ok_battery",
|
||||
])
|
||||
).toEqual(["binary_sensor.low_battery"]);
|
||||
});
|
||||
|
||||
it("updates the device lookup when the entity registry changes", () => {
|
||||
const states = {
|
||||
"sensor.battery": createMockEntityState("sensor.battery", "10", {
|
||||
device_class: "battery",
|
||||
}),
|
||||
"binary_sensor.battery_charging": createMockEntityState(
|
||||
"binary_sensor.battery_charging",
|
||||
"on",
|
||||
{ device_class: "battery_charging" }
|
||||
),
|
||||
};
|
||||
|
||||
const firstHass = createMockHass(states, {
|
||||
entities: {
|
||||
"sensor.battery": mockEntity({
|
||||
entity_id: "sensor.battery",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
"binary_sensor.battery_charging": mockEntity({
|
||||
entity_id: "binary_sensor.battery_charging",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(filterLowBatteryEntities(firstHass, ["sensor.battery"])).toEqual([]);
|
||||
|
||||
const secondHass = createMockHass(states, {
|
||||
entities: {
|
||||
"sensor.battery": mockEntity({
|
||||
entity_id: "sensor.battery",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
"binary_sensor.battery_charging": mockEntity({
|
||||
entity_id: "binary_sensor.battery_charging",
|
||||
device_id: "device_2",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(filterLowBatteryEntities(secondHass, ["sensor.battery"])).toEqual([
|
||||
"sensor.battery",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3502,13 +3502,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/mapbox-gl-rtl-text@npm:0.4.0":
|
||||
version: 0.4.0
|
||||
resolution: "@mapbox/mapbox-gl-rtl-text@npm:0.4.0"
|
||||
checksum: 10/a678240e4cc6f589726ef2f35fac64a7eb073451a8686c09db58041d3fb351d2e72100bb12607591efc298d8ac5e19691c593b3c213d121b2a0e84978254e1a8
|
||||
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"
|
||||
@@ -5958,105 +5951,105 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/eslint-plugin@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.68.0"
|
||||
"@typescript-eslint/eslint-plugin@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
|
||||
dependencies:
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
"@typescript-eslint/scope-manager": "npm:8.68.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.68.0"
|
||||
"@typescript-eslint/utils": "npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.68.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.67.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.67.0"
|
||||
"@typescript-eslint/utils": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
ignore: "npm:^7.0.5"
|
||||
natural-compare: "npm:^1.4.0"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
"@typescript-eslint/parser": ^8.68.0
|
||||
"@typescript-eslint/parser": ^8.67.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/8776fd8ffde8eceae3b1aeca57e528e8ee61a683223c3bef455ca3c5e28e52275e6cd6ebf8ee284a46883e6220bbf297312084e1ee85e00cef017a78c169a674
|
||||
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/parser@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.68.0"
|
||||
"@typescript-eslint/parser@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.68.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.67.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/979ae0d2c6a391fd5b9b0218189fda5112ff0b5ab2538eef1da4cd77ac8ae46f9b590db9779f2614bd193105b40cf12635b565c840f4cdbbb4a683e24cf5ec4b
|
||||
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/project-service@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.68.0"
|
||||
"@typescript-eslint/project-service@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.68.0"
|
||||
"@typescript-eslint/types": "npm:^8.68.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
|
||||
"@typescript-eslint/types": "npm:^8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/37521df3731f9069c7df55362342dd0027f43947241de33c2a1c029b4553fe092e2ac69a534153aaf08c862e6b2390d864cb6c88a0714fc276395390df276fb6
|
||||
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/scope-manager@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.68.0"
|
||||
"@typescript-eslint/scope-manager@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.68.0"
|
||||
checksum: 10/494d499315b4f3ab8eaa114aa53a4b2b9f533b82eb007cd5b5617cececfd8ad6938edb74f734479ae6e40a4086f510b6b984de8e66d06c518cb47817549db780
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.68.0, @typescript-eslint/tsconfig-utils@npm:^8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.68.0"
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/b527a7c5deaad40bb72f78eec1bfea5997a7e792e64666984605e9a167246cf856c8d40290a7b3d39cefb3a242538e22a3a4fc136132e4669a314887e2de5f08
|
||||
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/type-utils@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.68.0"
|
||||
"@typescript-eslint/type-utils@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.68.0"
|
||||
"@typescript-eslint/utils": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
"@typescript-eslint/utils": "npm:8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/ed6b6fe74eee017a79c9dbfa72b3a08748dac8de5702314494d8849feee516c20e2907ad730fd82e50be81c5984c575b29e35eb8b5c99091445b4bc5102ce836
|
||||
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/types@npm:8.68.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/types@npm:8.68.0"
|
||||
checksum: 10/f9337e0263d95e6edca6e364f9b1f70d5840b9648b5aa0d02065bcee5809a50c92d024ba17b458182ec4e7fffe5efd05584f1667b1e43ff3e7121b218283a207
|
||||
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/types@npm:8.67.0"
|
||||
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/typescript-estree@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service": "npm:8.68.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.68.0"
|
||||
"@typescript-eslint/project-service": "npm:8.67.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
minimatch: "npm:^10.2.2"
|
||||
semver: "npm:^7.7.3"
|
||||
@@ -6064,32 +6057,32 @@ __metadata:
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/47d75cbc9c10842aeb3c1cc8d0625bd578b3b43b75d3dc86d3543a739b2fc8d0b524fd86b5af1f1493a3f0ae1b9e65c3200bf01a6bde0162efe6414708eac553
|
||||
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/utils@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.68.0"
|
||||
"@typescript-eslint/utils@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.67.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
||||
"@typescript-eslint/scope-manager": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.68.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.67.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/6581a1e7ced23cce82279df001d643844c97408bc84826da77ec77d52b77047004b202ff8cfcfbc0b0fef15bf2cafb5ae21bafbda24c9b58102ce634f66c2af9
|
||||
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/visitor-keys@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
eslint-visitor-keys: "npm:^5.0.0"
|
||||
checksum: 10/d802bc2c4569495f3d83f9bcf169e3a432ac1c6672e243399886409dbc55d7fa30375d9d0b2cf0d9466b81fc2b01b991483eb56e8cdb8141721009978d3b078b
|
||||
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9104,9 +9097,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint@npm:10.9.1":
|
||||
version: 10.9.1
|
||||
resolution: "eslint@npm:10.9.1"
|
||||
"eslint@npm:10.9.0":
|
||||
version: 10.9.0
|
||||
resolution: "eslint@npm:10.9.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.8.0"
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
@@ -9145,7 +9138,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
eslint: bin/eslint.js
|
||||
checksum: 10/4cee237c7d2c445a05330e595b18521d7452fd3f419241bf1ccf81c769a6761c10ca5a06988197482d4842ed1a26b1dcb51b9526408990cee84559e6c08cb3ab
|
||||
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10155,7 +10148,6 @@ __metadata:
|
||||
"@lit/reactive-element": "npm:2.1.2"
|
||||
"@lit/task": "npm:1.0.3"
|
||||
"@lokalise/node-api": "npm:16.3.0"
|
||||
"@mapbox/mapbox-gl-rtl-text": "npm:0.4.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"
|
||||
@@ -10214,7 +10206,7 @@ __metadata:
|
||||
echarts: "npm:6.1.0"
|
||||
echarts-extension-chart2music: "npm:0.1.1"
|
||||
element-internals-polyfill: "npm:3.0.2"
|
||||
eslint: "npm:10.9.1"
|
||||
eslint: "npm:10.9.0"
|
||||
eslint-config-prettier: "npm:10.1.8"
|
||||
eslint-import-resolver-webpack: "npm:0.13.11"
|
||||
eslint-plugin-import-x: "npm:4.17.1"
|
||||
@@ -10237,7 +10229,7 @@ __metadata:
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.14"
|
||||
js-yaml: "npm:5.4.0"
|
||||
js-yaml: "npm:5.3.0"
|
||||
jsdom: "npm:30.0.1"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
@@ -10254,7 +10246,7 @@ __metadata:
|
||||
luxon: "npm:3.7.2"
|
||||
map-stream: "npm:0.0.7"
|
||||
maplibre-gl: "npm:5.24.0"
|
||||
marked: "npm:18.0.11"
|
||||
marked: "npm:18.0.10"
|
||||
memoize-one: "npm:6.0.0"
|
||||
minify-literals: "npm:2.2.0"
|
||||
node-vibrant: "npm:4.0.4"
|
||||
@@ -10277,7 +10269,7 @@ __metadata:
|
||||
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
|
||||
ts-lit-plugin: "npm:2.0.2"
|
||||
typescript: "npm:6.0.3"
|
||||
typescript-eslint: "npm:8.68.0"
|
||||
typescript-eslint: "npm:8.67.0"
|
||||
vite-tsconfig-paths: "npm:6.1.1"
|
||||
vitest: "npm:4.1.11"
|
||||
webpack-stats-plugin: "npm:1.1.3"
|
||||
@@ -11185,14 +11177,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:5.4.0":
|
||||
version: 5.4.0
|
||||
resolution: "js-yaml@npm:5.4.0"
|
||||
"js-yaml@npm:5.3.0":
|
||||
version: 5.3.0
|
||||
resolution: "js-yaml@npm:5.3.0"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.mjs
|
||||
checksum: 10/d8216083212772b203a1ee16be2ea96a1638c6d7f0ff769ba4da274b5ad29e858bd26d96761feb8937fce1aa47e8acfa79b2cd21a8ced724cc5555c11b4d056c
|
||||
checksum: 10/6c7e8ea8dd5643d9df73f4aa796f3572537284e9b819b74ef919467452bfa4624f8f7477cfc775c91db7e2f32387133b1b6c4e95aef9702be6bb042ae554884a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11952,12 +11944,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:18.0.11":
|
||||
version: 18.0.11
|
||||
resolution: "marked@npm:18.0.11"
|
||||
"marked@npm:18.0.10":
|
||||
version: 18.0.10
|
||||
resolution: "marked@npm:18.0.10"
|
||||
bin:
|
||||
marked: bin/marked.js
|
||||
checksum: 10/6da69f17820c7442358d751e902d3e0e06fef957e1178d634db938cf339c4504399c9f11a953b0a116b1e5bebc1c7fb52726777cc6c351f2c9f39e4a8276e21a
|
||||
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15240,18 +15232,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript-eslint@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "typescript-eslint@npm:8.68.0"
|
||||
"typescript-eslint@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "typescript-eslint@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.68.0"
|
||||
"@typescript-eslint/parser": "npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.68.0"
|
||||
"@typescript-eslint/utils": "npm:8.68.0"
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
|
||||
"@typescript-eslint/parser": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
"@typescript-eslint/utils": "npm:8.67.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/451c64296ac5f48e4621a03324f5aaef4a26f1dd9d4df63bd3cb07f136e8379185a800731f83634eb5d909c696fad820ba74d31ab3d7cf263328504275d1305b
|
||||
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user