mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-29 02:16:15 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
863125d9b0 | ||
|
|
0531888064 | ||
|
|
24e26b398e |
@@ -74,6 +74,3 @@ test/e2e/app/dist/
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
# Downloaded map glyph and sprite archives
|
||||
.map-assets/
|
||||
|
||||
@@ -4,7 +4,6 @@ import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
@@ -90,7 +89,7 @@ function copyQrScannerWorker(staticDir) {
|
||||
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
|
||||
}
|
||||
|
||||
async function copyMapPanel(staticDir) {
|
||||
function copyMapPanel(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
copyFileDir(
|
||||
npmPath("leaflet/dist/leaflet.css"),
|
||||
@@ -104,10 +103,6 @@ async function copyMapPanel(staticDir) {
|
||||
npmPath("leaflet/dist/images"),
|
||||
staticPath("images/leaflet/images/")
|
||||
);
|
||||
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
@@ -144,7 +139,7 @@ gulp.task("copy-static-app", async () => {
|
||||
copyMdiIcons(staticDir);
|
||||
|
||||
// Panel assets
|
||||
await copyMapPanel(staticDir);
|
||||
copyMapPanel(staticDir);
|
||||
|
||||
// Qr Scanner assets
|
||||
copyZXingWasm(staticDir);
|
||||
@@ -160,7 +155,7 @@ gulp.task("copy-static-demo", async () => {
|
||||
// Copy demo static files
|
||||
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
|
||||
copyPolyfills(paths.demo_output_static);
|
||||
await copyMapPanel(paths.demo_output_static);
|
||||
copyMapPanel(paths.demo_output_static);
|
||||
copyFonts(paths.demo_output_static);
|
||||
copyTranslations(paths.demo_output_static);
|
||||
copyLocaleData(paths.demo_output_static);
|
||||
@@ -173,7 +168,7 @@ gulp.task("copy-static-cast", async () => {
|
||||
// Copy cast static files
|
||||
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
|
||||
copyPolyfills(paths.cast_output_static);
|
||||
await copyMapPanel(paths.cast_output_static);
|
||||
copyMapPanel(paths.cast_output_static);
|
||||
copyFonts(paths.cast_output_static);
|
||||
copyTranslations(paths.cast_output_static);
|
||||
copyLocaleData(paths.cast_output_static);
|
||||
@@ -189,7 +184,7 @@ gulp.task("copy-static-gallery", async () => {
|
||||
paths.gallery_output_root
|
||||
);
|
||||
|
||||
await copyMapPanel(paths.gallery_output_static);
|
||||
copyMapPanel(paths.gallery_output_static);
|
||||
copyFonts(paths.gallery_output_static);
|
||||
copyTranslations(paths.gallery_output_static);
|
||||
copyLocaleData(paths.gallery_output_static);
|
||||
@@ -220,7 +215,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
await copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
|
||||
@@ -14,7 +14,6 @@ import "./gen-icons-json.js";
|
||||
import "./gen-sensor-entity-constants.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./map-assets.js";
|
||||
import "./rspack.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
// Assembles the static assets of the vector base map - style, SDF glyphs and
|
||||
// icon sprites - into /static/map/. vector.openstreetmap.org sets CORS headers
|
||||
// on its tiles only, so these cannot be loaded from there by a browser.
|
||||
//
|
||||
// Glyphs and sprites come from pinned VersaTiles releases, cached locally and
|
||||
// verified against a digest.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { colorful, eclipse } from "@versatiles/style";
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import { extract } from "tar";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
// The tile URL is deliberately never named here: the OSMF asks consumers to
|
||||
// resolve it through the TileJSON so they can move the tiles without every
|
||||
// client needing a release. https://operations.osmfoundation.org/policies/vector/
|
||||
const TILEJSON_URL =
|
||||
"https://vector.openstreetmap.org/shortbread_v1/tilejson.json";
|
||||
|
||||
const ASSET_PATH = "/static/map";
|
||||
|
||||
const ARCHIVES = {
|
||||
fonts: {
|
||||
url: "https://github.com/versatiles-org/versatiles-fonts/releases/download/v2.2.0/noto_sans.tar.gz",
|
||||
sha256: "a2dac39f4096722bc420367ffd4a36687cce7229e8aa760bc12cf657072eea6b",
|
||||
},
|
||||
sprites: {
|
||||
url: "https://github.com/versatiles-org/versatiles-style/releases/download/v5.13.1/sprites.tar.gz",
|
||||
sha256: "efffd0ee4cb9591bd52f16ff5b269d9618c7dd1db159cd6511943965560ddea5",
|
||||
},
|
||||
};
|
||||
|
||||
// Rendered with a device font through `localIdeographFontFamily`, so these are
|
||||
// never downloaded - and they are 90% of the Noto Sans SDF set.
|
||||
const LOCAL_IDEOGRAPH_BLOCKS = [
|
||||
[0x2e80, 0x9fff], // CJK radicals through CJK Unified Ideographs, incl. kana
|
||||
[0xac00, 0xd7ff], // Hangul syllables
|
||||
[0xf900, 0xfaff], // CJK compatibility ideographs
|
||||
[0xfe30, 0xfe4f], // CJK compatibility forms
|
||||
];
|
||||
|
||||
// Our styles use bold only for motorway shields, so Latin, Greek and Cyrillic
|
||||
// cover every ref and the rest of bold - half the glyph set - is dropped.
|
||||
// `assertBoldStaysOnRefs` guards the assumption.
|
||||
const BOLD_MAX_CODEPOINT = 0x04ff;
|
||||
const BOLD_TEXT_FIELD = "{ref}";
|
||||
|
||||
const cacheDir = path.resolve(paths.root_dir, ".map-assets");
|
||||
const outputDir = path.resolve(paths.build_dir, "map");
|
||||
|
||||
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
// Downloads an archive into the cache, or reuses it when the digest matches.
|
||||
const cachedArchive = async (name, { url, sha256: expected }) => {
|
||||
const file = path.join(cacheDir, `${name}.tar.gz`);
|
||||
|
||||
if (await fs.pathExists(file)) {
|
||||
if (sha256(await readFile(file)) === expected) {
|
||||
return file;
|
||||
}
|
||||
console.warn("Cached map %s archive is stale, downloading again", name);
|
||||
}
|
||||
|
||||
console.log("Downloading map %s from %s", name, url);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download map ${name}: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
const digest = sha256(body);
|
||||
if (digest !== expected) {
|
||||
throw new Error(
|
||||
`Digest mismatch for map ${name}: expected ${expected}, got ${digest}`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.outputFile(file, body);
|
||||
return file;
|
||||
};
|
||||
|
||||
const glyphRange = (entryPath) => {
|
||||
const match = /^(?<font>[^/]+)\/(?<start>\d+)-(?<end>\d+)\.pbf$/.exec(
|
||||
entryPath
|
||||
);
|
||||
return match
|
||||
? { font: match.groups.font, start: Number(match.groups.start) }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const keepGlyph = (entryPath) => {
|
||||
const range = glyphRange(entryPath);
|
||||
if (!range) {
|
||||
return false;
|
||||
}
|
||||
if (range.font.endsWith("_bold") && range.start > BOLD_MAX_CODEPOINT) {
|
||||
return false;
|
||||
}
|
||||
return !LOCAL_IDEOGRAPH_BLOCKS.some(
|
||||
([from, to]) => range.start >= from && range.start <= to
|
||||
);
|
||||
};
|
||||
|
||||
const keepSprite = (entryPath) =>
|
||||
/^basics\/sprites(@2x)?\.(json|png)$/.test(entryPath);
|
||||
|
||||
// `neutrino`, for one, sets country and state labels in bold - names in any
|
||||
// script, which would turn to tofu. Fail the build rather than ship that.
|
||||
const assertBoldStaysOnRefs = (name, style) => {
|
||||
const offenders = style.layers
|
||||
.filter((layer) =>
|
||||
(layer.layout?.["text-font"] ?? []).some((font) => font.endsWith("_bold"))
|
||||
)
|
||||
.filter((layer) => layer.layout["text-field"] !== BOLD_TEXT_FIELD)
|
||||
.map((layer) => layer.id);
|
||||
|
||||
if (offenders.length) {
|
||||
throw new Error(
|
||||
`Style "${name}" uses bold for ${offenders.join(", ")}, which can hold ` +
|
||||
`names in any script. Raise BOLD_MAX_CODEPOINT to cover the full set ` +
|
||||
`before shipping this style.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// MapLibre extends the fetched TileJSON with the style's source options, so
|
||||
// anything left here wins and freezes at build time. Dropping them is what lets
|
||||
// a remote switch move the attribution and zoom range too, not just the URLs.
|
||||
const TILEJSON_FIELDS = [
|
||||
"tiles",
|
||||
"attribution",
|
||||
"bounds",
|
||||
"minzoom",
|
||||
"maxzoom",
|
||||
"scheme",
|
||||
];
|
||||
|
||||
// The builder can only write a tile URL, so the source is repointed afterwards.
|
||||
// Keyed on there being exactly one source: any other shape means the builder's
|
||||
// own default host would ship unnoticed.
|
||||
const useTileJson = (name, style) => {
|
||||
const sources = Object.values(style.sources);
|
||||
|
||||
if (sources.length !== 1) {
|
||||
throw new Error(
|
||||
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
|
||||
`point at the TileJSON. Check what @versatiles/style emits.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of TILEJSON_FIELDS) {
|
||||
delete sources[0][field];
|
||||
}
|
||||
sources[0].url = TILEJSON_URL;
|
||||
return style;
|
||||
};
|
||||
|
||||
const styleOptions = {
|
||||
// Keeps the generated URLs origin relative.
|
||||
baseUrl: "",
|
||||
glyphs: `${ASSET_PATH}/fonts/{fontstack}/{range}.pbf`,
|
||||
sprite: [{ id: "basics", url: `${ASSET_PATH}/sprites/basics/sprites` }],
|
||||
};
|
||||
|
||||
const buildMapAssets = async () => {
|
||||
const [fontArchive, spriteArchive] = await Promise.all([
|
||||
cachedArchive("fonts", ARCHIVES.fonts),
|
||||
cachedArchive("sprites", ARCHIVES.sprites),
|
||||
]);
|
||||
|
||||
await fs.emptyDir(outputDir);
|
||||
await Promise.all([
|
||||
fs.ensureDir(path.join(outputDir, "fonts")),
|
||||
fs.ensureDir(path.join(outputDir, "sprites")),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
extract({
|
||||
file: fontArchive,
|
||||
cwd: path.join(outputDir, "fonts"),
|
||||
filter: keepGlyph,
|
||||
}),
|
||||
extract({
|
||||
file: spriteArchive,
|
||||
cwd: path.join(outputDir, "sprites"),
|
||||
filter: keepSprite,
|
||||
}),
|
||||
// Both themes up front: dark is a real cartography, not an inverted raster.
|
||||
...[
|
||||
["light", colorful],
|
||||
["dark", eclipse],
|
||||
].map(([name, builder]) => {
|
||||
const style = useTileJson(name, builder(styleOptions));
|
||||
assertBoldStaysOnRefs(name, style);
|
||||
return writeFile(
|
||||
path.join(outputDir, `${name}.json`),
|
||||
JSON.stringify(style)
|
||||
);
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
// Shared so it does not have to be wired into every pipeline separately.
|
||||
let pending;
|
||||
export const ensureMapAssets = () => {
|
||||
pending ??= buildMapAssets();
|
||||
return pending;
|
||||
};
|
||||
|
||||
gulp.task("build-map-assets", ensureMapAssets);
|
||||
|
||||
export const mapAssetsDir = outputDir;
|
||||
@@ -19,7 +19,6 @@ const baseEntry = {
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
|
||||
@@ -424,23 +424,6 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
device_class: {
|
||||
name: "Device Class",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
},
|
||||
},
|
||||
},
|
||||
device_class_multiple: {
|
||||
name: "Device Class (Multiple)",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "binary_sensor",
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select_custom: {
|
||||
name: "Select (Custom)",
|
||||
selector: {
|
||||
|
||||
@@ -39,7 +39,6 @@ const createConfigEntry = (
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
...override,
|
||||
|
||||
+2
-5
@@ -75,7 +75,6 @@
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.5.0",
|
||||
@@ -95,7 +94,7 @@
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.6.3",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
@@ -116,7 +115,6 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.10",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -145,7 +143,7 @@
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.1",
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
@@ -172,7 +170,6 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@versatiles/style": "5.13.1",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260826.1"
|
||||
version = "20260729.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -27,7 +27,6 @@ const ALLOWED_LICENSES = new Set([
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
import type { Map } from "leaflet";
|
||||
import type { MapBaseLayer } from "../map/base-layer";
|
||||
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
|
||||
import type { Map, TileLayer } from "leaflet";
|
||||
|
||||
// Sets up a Leaflet map on the provided DOM element
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletModuleType = typeof import("leaflet");
|
||||
|
||||
export interface LeafletMapSetup {
|
||||
map: Map;
|
||||
leaflet: LeafletModuleType;
|
||||
baseLayer: MapBaseLayer;
|
||||
}
|
||||
|
||||
export const setupLeafletMap = async (
|
||||
mapElement: HTMLElement,
|
||||
initialView?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
darkMode?: boolean;
|
||||
}
|
||||
): Promise<LeafletMapSetup> => {
|
||||
initialView?: { latitude: number; longitude: number; zoom?: number }
|
||||
): Promise<[Map, LeafletModuleType, TileLayer]> => {
|
||||
if (!mapElement.parentNode) {
|
||||
throw new Error("Cannot setup Leaflet map on disconnected element");
|
||||
}
|
||||
@@ -30,11 +17,7 @@ export const setupLeafletMap = async (
|
||||
|
||||
await import("leaflet.markercluster");
|
||||
|
||||
const map = Leaflet.map(mapElement, {
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
});
|
||||
map.attributionControl.setPrefix("");
|
||||
const map = Leaflet.map(mapElement);
|
||||
const style = document.createElement("link");
|
||||
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
|
||||
style.setAttribute("rel", "stylesheet");
|
||||
@@ -55,13 +38,21 @@ export const setupLeafletMap = async (
|
||||
);
|
||||
}
|
||||
|
||||
// The base layer adds itself: the vector layer only builds its MapLibre map
|
||||
// once it is on the map, and that failing has to fall back to raster.
|
||||
const baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
initialView?.darkMode ?? false
|
||||
);
|
||||
const tileLayer = createTileLayer(Leaflet).addTo(map);
|
||||
|
||||
return { map, leaflet: Leaflet, baseLayer };
|
||||
return [map, Leaflet, tileLayer];
|
||||
};
|
||||
|
||||
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
|
||||
leaflet.tileLayer(
|
||||
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
|
||||
leaflet.Browser.retina ? "@2x.png" : ".png"
|
||||
}`,
|
||||
{
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: "abcd",
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -33,10 +33,7 @@ const normalizeFilterArray = <T>(
|
||||
};
|
||||
|
||||
export const generateEntityFilter = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>,
|
||||
hass: HomeAssistant,
|
||||
filter: EntityFilter
|
||||
): EntityFilterFunc => {
|
||||
const domains = filter.domain
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
|
||||
import type { Map as LeafletMap } from "leaflet";
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
|
||||
|
||||
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
|
||||
// endpoint sends CORS headers, so the style, glyphs and sprites are ours to
|
||||
// serve - see build-scripts/gulp/map-assets.js. The credit comes from the
|
||||
// TileJSON rather than from here, deliberately: it follows whoever serves the
|
||||
// tiles.
|
||||
const VECTOR_STYLES = {
|
||||
light: "/static/map/light.json",
|
||||
dark: "/static/map/dark.json",
|
||||
} as const;
|
||||
|
||||
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
|
||||
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
|
||||
// raster blocks a browser that sends no Referer, and the only referrer a browser
|
||||
// can send is its origin, which identifies a Nabu Casa installation.
|
||||
const RASTER_TILE_URL = "https://basemaps.cartocdn.com/rastertiles/voyager";
|
||||
const CARTO_ATTRIBUTION =
|
||||
'© <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.
|
||||
const CONTEXT_RESTORE_GRACE = 2000;
|
||||
|
||||
// On the map, not the layer: only Leaflet tile layers report their own limits,
|
||||
// and marker clustering throws without a maximum. The floor is 1 because at
|
||||
// Leaflet zoom 0 the adapter drives MapLibre to -1, outside its range.
|
||||
export const MAP_MIN_ZOOM = 1;
|
||||
export const MAP_MAX_ZOOM = 20;
|
||||
|
||||
export interface MapBaseLayer {
|
||||
// A no-op for raster, which has no dark variant and is inverted in CSS.
|
||||
setDarkMode: (darkMode: boolean) => void;
|
||||
}
|
||||
|
||||
let webGL2Supported: boolean | undefined;
|
||||
|
||||
// Rules out iOS below 15, older Android tablets, and blocklisted drivers.
|
||||
const supportsWebGL2 = (): boolean => {
|
||||
if (webGL2Supported === undefined) {
|
||||
try {
|
||||
const context = document.createElement("canvas").getContext("webgl2");
|
||||
webGL2Supported = Boolean(context);
|
||||
// Contexts are scarce; the probe must not keep one.
|
||||
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
} catch {
|
||||
webGL2Supported = false;
|
||||
}
|
||||
}
|
||||
return webGL2Supported;
|
||||
};
|
||||
|
||||
// Asset URLs are stored origin relative so they follow the instance's host, but
|
||||
// MapLibre rejects a relative sprite URL. The glyph URL is left alone: URL
|
||||
// encoding would mangle its {fontstack} and {range} placeholders.
|
||||
const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
const style: StyleSpecification = await (await fetch(url)).json();
|
||||
if (typeof style.sprite === "string") {
|
||||
style.sprite = new URL(style.sprite, location.href).href;
|
||||
} else if (Array.isArray(style.sprite)) {
|
||||
style.sprite = style.sprite.map((sprite) => ({
|
||||
...sprite,
|
||||
url: new URL(sprite.url, location.href).href,
|
||||
}));
|
||||
}
|
||||
return style;
|
||||
};
|
||||
|
||||
const createVectorLayer = async (
|
||||
createLayer: typeof maplibreGL,
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean
|
||||
): Promise<MapBaseLayer | undefined> => {
|
||||
let layer: ReturnType<typeof maplibreGL> | undefined;
|
||||
|
||||
try {
|
||||
layer = createLayer({
|
||||
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
|
||||
// Draws CJK, kana and hangul with a device font, which is why we ship a
|
||||
// tenth of the glyph set. No referrer is set: the endpoint serves without
|
||||
// one, and the only one a browser can send identifies the installation.
|
||||
localIdeographFontFamily: "sans-serif",
|
||||
});
|
||||
// The plugin builds the MapLibre map in `onAdd`, so a refused context, a
|
||||
// blocked worker or a rejected blob URL throws here. Keep it inside the
|
||||
// guard or those lose the raster fallback.
|
||||
layer.addTo(map);
|
||||
} catch {
|
||||
if (layer) {
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
// May never have finished being added.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Tracked apart because a failed request must roll back to what is displayed,
|
||||
// not to the opposite of what it asked for - with several in flight those are
|
||||
// different, and guessing wrong makes the next toggle a permanent no-op.
|
||||
let appliedDarkMode = darkMode;
|
||||
let requestedDarkMode = darkMode;
|
||||
// Styles are fetched, so only the newest request may touch the map.
|
||||
let latestRequest = 0;
|
||||
let vector = true;
|
||||
|
||||
const glMap = layer.getMaplibreMap();
|
||||
let fallbackTimeout: number | undefined;
|
||||
let contextLost = false;
|
||||
|
||||
// Declared first, but only ever called once all three exist.
|
||||
const handleVisibilityChange = () => {
|
||||
if (contextLost) {
|
||||
scheduleSwap();
|
||||
}
|
||||
};
|
||||
|
||||
const swapToRaster = () => {
|
||||
vector = false;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
// Nothing left to detach.
|
||||
}
|
||||
createRasterLayer(leaflet, map);
|
||||
};
|
||||
|
||||
const scheduleSwap = () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
// Backgrounding also drops the context, and there it comes back on return.
|
||||
// Running the clock then would make switching apps enough to lose vector.
|
||||
if (!vector || document.hidden) {
|
||||
return;
|
||||
}
|
||||
fallbackTimeout = window.setTimeout(swapToRaster, CONTEXT_RESTORE_GRACE);
|
||||
};
|
||||
|
||||
glMap.on("webglcontextlost", () => {
|
||||
contextLost = true;
|
||||
scheduleSwap();
|
||||
});
|
||||
glMap.on("webglcontextrestored", () => {
|
||||
contextLost = false;
|
||||
clearTimeout(fallbackTimeout);
|
||||
});
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
map.on("unload", () => {
|
||||
// Otherwise the timer revives a map that is already gone.
|
||||
clearTimeout(fallbackTimeout);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
|
||||
return {
|
||||
setDarkMode: (newDarkMode: boolean) => {
|
||||
if (!vector || newDarkMode === requestedDarkMode) {
|
||||
return;
|
||||
}
|
||||
requestedDarkMode = newDarkMode;
|
||||
const request = ++latestRequest;
|
||||
|
||||
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
|
||||
.then((style) => {
|
||||
if (request === latestRequest) {
|
||||
appliedDarkMode = newDarkMode;
|
||||
layer.getMaplibreMap()?.setStyle(style);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (request === latestRequest) {
|
||||
requestedDarkMode = appliedDarkMode;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createRasterLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap
|
||||
): MapBaseLayer => {
|
||||
leaflet
|
||||
.tileLayer(
|
||||
// These are the old retina tablets, and CARTO serves @2x - sharp tiles at
|
||||
// the same request count, where `detectRetina` would fetch a zoom deeper
|
||||
// at four times as many.
|
||||
`${RASTER_TILE_URL}/{z}/{x}/{y}${leaflet.Browser.retina ? "@2x" : ""}.png`,
|
||||
{
|
||||
attribution: CARTO_ATTRIBUTION,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
}
|
||||
)
|
||||
.addTo(map);
|
||||
|
||||
return { setDarkMode: () => undefined };
|
||||
};
|
||||
|
||||
export const createBaseLayer = async (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean
|
||||
): Promise<MapBaseLayer> => {
|
||||
if (supportsWebGL2()) {
|
||||
let vectorLayer: MapBaseLayer | undefined;
|
||||
try {
|
||||
const { maplibreGL: createLayer } =
|
||||
await import("@maplibre/maplibre-gl-leaflet");
|
||||
vectorLayer = await createVectorLayer(
|
||||
createLayer,
|
||||
leaflet,
|
||||
map,
|
||||
darkMode
|
||||
);
|
||||
} catch {
|
||||
// No chunk, no vector map - but still a map.
|
||||
}
|
||||
if (vectorLayer) {
|
||||
return vectorLayer;
|
||||
}
|
||||
}
|
||||
return createRasterLayer(leaflet, map);
|
||||
};
|
||||
@@ -10,16 +10,12 @@ interface MeanFrame {
|
||||
}
|
||||
|
||||
interface MinMaxFrame {
|
||||
// A frame can hold a gap marker before any value lands in it, so the min/max
|
||||
// slots below only mean something once this is true.
|
||||
hasValue: boolean;
|
||||
minPoint: Point;
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxPoint: Point;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
gapPoint: Point | undefined;
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
@@ -53,25 +49,6 @@ function snapFrameSize(step: number): number {
|
||||
return snapped;
|
||||
}
|
||||
|
||||
// y is NaN for a frame seeded by a gap marker, which has no value yet.
|
||||
function newFrame(
|
||||
point: Point,
|
||||
x: number,
|
||||
y: number,
|
||||
gapPoint: Point | undefined
|
||||
): MinMaxFrame {
|
||||
return {
|
||||
hasValue: gapPoint === undefined,
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
gapPoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function downSampleLineData<
|
||||
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
|
||||
>(
|
||||
@@ -105,10 +82,7 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
const rawY = pointData[1] as number | null;
|
||||
// Number(null) is 0, which would drag the mean towards zero
|
||||
if (rawY === null) continue;
|
||||
const y = Number(rawY);
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
@@ -146,34 +120,21 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
if (isNaN(x)) continue;
|
||||
const rawY = pointData[1] as number | null;
|
||||
if (rawY === null) {
|
||||
// The chart data modules push a null value to break the line where an
|
||||
// entity was unavailable. Number(null) is 0, so such a marker must stay
|
||||
// out of the comparisons below, where it would win the minimum slot
|
||||
// whenever the readings are positive and discard the frame's real
|
||||
// minimum. One marker per frame is enough to break the line, and keeping
|
||||
// them all would blow up the output on series that are mostly null. The
|
||||
// last one wins: where the break lands only depends on which points it
|
||||
// sits between, not on its own x.
|
||||
const gapIndex = Math.floor(x / step);
|
||||
const gapFrame = frames.get(gapIndex);
|
||||
if (gapFrame) {
|
||||
gapFrame.gapPoint = point;
|
||||
} else {
|
||||
frames.set(gapIndex, newFrame(point, x, NaN, point));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const y = Number(rawY);
|
||||
if (isNaN(y)) continue;
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, newFrame(point, x, y, undefined));
|
||||
} else if (frame.hasValue) {
|
||||
frames.set(frameIndex, {
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
});
|
||||
} else {
|
||||
// Match the original strict-less / strict-greater comparisons so the
|
||||
// first occurrence wins on ties.
|
||||
if (y < frame.minY) {
|
||||
@@ -186,40 +147,18 @@ export function downSampleLineData<
|
||||
frame.maxX = x;
|
||||
frame.maxY = y;
|
||||
}
|
||||
} else {
|
||||
// the frame held nothing but a marker so far
|
||||
frame.hasValue = true;
|
||||
frame.minPoint = point;
|
||||
frame.minX = x;
|
||||
frame.minY = y;
|
||||
frame.maxPoint = point;
|
||||
frame.maxX = x;
|
||||
frame.maxY = y;
|
||||
}
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
for (const frame of frames.values()) {
|
||||
if (frame.hasValue) {
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.minX > frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.minX > frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
if (frame.gapPoint !== undefined) {
|
||||
// A marker followed by a value in its own frame is a gap that closed
|
||||
// within one frame, which is about one device pixel: too narrow to show.
|
||||
// The kept points are exactly min and max, so comparing against the
|
||||
// later of the two catches that without any work on the ingest path. A
|
||||
// marker-only frame compares against its own x and always passes.
|
||||
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
|
||||
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
|
||||
result.push(frame.gapPoint as T);
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { fullEntitiesContext } from "../../data/context";
|
||||
import type { DeviceAutomation } from "../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationsEqual,
|
||||
deviceAutomationsSimilar,
|
||||
sortDeviceAutomations,
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
@@ -179,15 +180,12 @@ export abstract class HaDeviceAutomationPicker<
|
||||
(a, idx) => value === `${a.device_id}_${idx}`
|
||||
);
|
||||
|
||||
const described =
|
||||
automation ?? (this.value?.domain ? this.value : undefined);
|
||||
|
||||
const text = described
|
||||
const text = automation
|
||||
? this._localizeDeviceAutomation(
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this._entityReg,
|
||||
described
|
||||
automation
|
||||
)
|
||||
: value === NO_AUTOMATION_KEY
|
||||
? this.NO_AUTOMATION_TEXT
|
||||
@@ -197,24 +195,29 @@ export abstract class HaDeviceAutomationPicker<
|
||||
};
|
||||
|
||||
private async _updateDeviceInfo() {
|
||||
// Asking a removed device for its automations fails rather than returning
|
||||
// an empty list.
|
||||
this._automations = this.deviceId
|
||||
? (
|
||||
await this._fetchDeviceAutomations(
|
||||
this.hass.callWS,
|
||||
this.deviceId
|
||||
).catch(() => [] as T[])
|
||||
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
|
||||
).sort(sortDeviceAutomations)
|
||||
: // No device, clear the list of automations
|
||||
[];
|
||||
|
||||
// If there is no value, or if we have changed the device ID, reset the value.
|
||||
// If there is no value, or if we have changed the device ID, reset the
|
||||
// value. When the device changed (for example after replacing a removed
|
||||
// device), try to keep the same automation type/subtype on the new device
|
||||
// before falling back to the first available automation.
|
||||
if (!this.value || this.value.device_id !== this.deviceId) {
|
||||
const equivalent =
|
||||
this.value && this.deviceId
|
||||
? this._automations.find((automation) =>
|
||||
deviceAutomationsSimilar(automation, this.value!)
|
||||
)
|
||||
: undefined;
|
||||
this._setValue(
|
||||
this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId)
|
||||
equivalent ||
|
||||
(this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId))
|
||||
);
|
||||
}
|
||||
this._renderEmpty = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiSwapHorizontal } from "@mdi/js";
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-alert";
|
||||
@@ -105,14 +104,6 @@ export class HaDevicePicker extends LitElement {
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
/**
|
||||
* The split devices that can actually replace the current value, when the
|
||||
* caller knows better than this picker. Narrows the replacement candidates,
|
||||
* so the user is only asked to choose when there is a real choice.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public replacementDeviceIds?: string[];
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
@@ -196,8 +187,7 @@ export class HaDevicePicker extends LitElement {
|
||||
value: string | undefined,
|
||||
_devices: HomeAssistant["devices"],
|
||||
compositeSplits: DeviceCompositeSplits | undefined,
|
||||
items: (DevicePickerItem | string)[],
|
||||
replacementDeviceIds: string[] | undefined
|
||||
items: (DevicePickerItem | string)[]
|
||||
) => {
|
||||
if (!value || !compositeSplits || this.hass.devices[value]) {
|
||||
return undefined;
|
||||
@@ -213,11 +203,7 @@ export class HaDevicePicker extends LitElement {
|
||||
.filter((item): item is DevicePickerItem => typeof item !== "string")
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const candidates = split.split_ids.filter(
|
||||
(id) =>
|
||||
selectableIds.has(id) &&
|
||||
(!replacementDeviceIds || replacementDeviceIds.includes(id))
|
||||
);
|
||||
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
|
||||
return { candidates, primaryId: split.primary_id };
|
||||
}
|
||||
);
|
||||
@@ -264,26 +250,26 @@ export class HaDevicePicker extends LitElement {
|
||||
};
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
replacementName: string | undefined
|
||||
) =>
|
||||
(value: string) => {
|
||||
const deviceId = value;
|
||||
const device = this.hass.devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
// The removed device has no name left to show, so say what happened
|
||||
// to it instead. The alert below names the replacements. Without a
|
||||
// replacement, fall back to the normal "not found" display.
|
||||
if (isReplaced) {
|
||||
// When the device was replaced and a replacement is available, show
|
||||
// the replacement device's name. Otherwise fall back to the normal
|
||||
// "not found" display of the raw id.
|
||||
if (replacementName) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiSwapHorizontal}
|
||||
style="color: var(--warning-color)"
|
||||
.path=${mdiAlertOutline}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced"
|
||||
)}</span
|
||||
>
|
||||
<span slot="headline">${replacementName}</span>
|
||||
`;
|
||||
}
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
@@ -413,23 +399,31 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems(),
|
||||
this.replacementDeviceIds
|
||||
this._getItems()
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Only treat the value as "replaced" when there is an available
|
||||
// replacement device; otherwise fall back to normal "not found" behavior.
|
||||
const canReplace = !!replacement?.candidates.length;
|
||||
const replacementName = canReplace
|
||||
? computeDeviceName(
|
||||
this.hass.devices[
|
||||
replacement!.primaryId &&
|
||||
replacement!.candidates.includes(replacement!.primaryId)
|
||||
? replacement!.primaryId
|
||||
: replacement!.candidates[0]
|
||||
]
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const valueRenderer = this._valueRenderer(
|
||||
this._configEntryLookup,
|
||||
canReplace
|
||||
replacementName
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.noUnknownState=${canReplace}
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.disabled=${this.disabled}
|
||||
@@ -449,9 +443,14 @@ export class HaDevicePicker extends LitElement {
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.device-picker.unknown"
|
||||
)}
|
||||
.unknownItemText=${
|
||||
replacement?.candidates.length
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_count",
|
||||
{ count: replacement.candidates.length }
|
||||
)
|
||||
: this.hass.localize("ui.components.device-picker.unknown")
|
||||
}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
@@ -465,52 +464,30 @@ export class HaDevicePicker extends LitElement {
|
||||
}) {
|
||||
const { candidates } = replacement;
|
||||
|
||||
// The split devices all inherit the composite's name, so the integration is
|
||||
// what tells them apart.
|
||||
const replacementDevice =
|
||||
candidates.length === 1 ? this.hass.devices[candidates[0]] : undefined;
|
||||
const replacementName = replacementDevice
|
||||
? computeDeviceName(replacementDevice)
|
||||
: undefined;
|
||||
const replacementDomain = replacementDevice?.primary_config_entry
|
||||
? this._configEntryLookup[replacementDevice.primary_config_entry]?.domain
|
||||
: undefined;
|
||||
const replacementName =
|
||||
candidates.length === 1
|
||||
? computeDeviceName(this.hass.devices[candidates[0]])
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-alert alert-type="warning">
|
||||
${
|
||||
replacementName && replacementDomain
|
||||
replacementName
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one_integration",
|
||||
{
|
||||
device: replacementName,
|
||||
integration: domainToName(
|
||||
this.hass.localize,
|
||||
replacementDomain
|
||||
),
|
||||
}
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
)
|
||||
: replacementName
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
)
|
||||
}
|
||||
<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
variant="warning"
|
||||
@click=${this._handleReplace}
|
||||
>
|
||||
${
|
||||
candidates.length === 1
|
||||
? this.hass.localize("ui.components.device-picker.replace_update")
|
||||
: this.hass.localize("ui.components.device-picker.replace_choose")
|
||||
}
|
||||
${this.hass.localize("ui.components.device-picker.replace_device")}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`;
|
||||
@@ -521,8 +498,7 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems(),
|
||||
this.replacementDeviceIds
|
||||
this._getItems()
|
||||
);
|
||||
if (!replacement?.candidates.length) {
|
||||
return;
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
|
||||
import { computeDeviceClassName } from "../data/entity/device_class";
|
||||
import type {
|
||||
HomeAssistantInternationalization,
|
||||
ValueChangedEvent,
|
||||
} from "../types";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-input-chip";
|
||||
import "./ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
|
||||
export const getDeviceClassOptions = (
|
||||
domain: string,
|
||||
localize: LocalizeFunc
|
||||
): PickerComboBoxItem[] =>
|
||||
(DOMAIN_DEVICE_CLASSES[domain] ?? []).map((deviceClass) => {
|
||||
const primary = computeDeviceClassName(localize, domain, deviceClass);
|
||||
return { id: deviceClass, primary, sorting_label: primary };
|
||||
});
|
||||
|
||||
@customElement("ha-device-class-picker")
|
||||
export class HaDeviceClassPicker extends LitElement {
|
||||
@property() public domain?: string;
|
||||
|
||||
@property({ attribute: false }) public value?: string | string[];
|
||||
|
||||
@property({ type: Boolean }) public multiple = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
private _loadedDomain?: string;
|
||||
|
||||
protected willUpdate() {
|
||||
if (!this.domain || !this._i18n || this._loadedDomain === this.domain) {
|
||||
return;
|
||||
}
|
||||
this._loadedDomain = this.domain;
|
||||
this._i18n.loadBackendTranslation("entity_component", this.domain);
|
||||
}
|
||||
|
||||
private get _value(): string[] {
|
||||
return this.value ? ensureArray(this.value) : [];
|
||||
}
|
||||
|
||||
private _deviceClassName(deviceClass: string): string {
|
||||
return this._i18n && this.domain
|
||||
? computeDeviceClassName(this._i18n.localize, this.domain, deviceClass)
|
||||
: deviceClass;
|
||||
}
|
||||
|
||||
private _options = memoizeOne(
|
||||
(
|
||||
domain: string | undefined,
|
||||
localize: LocalizeFunc | undefined
|
||||
): PickerComboBoxItem[] =>
|
||||
domain && localize ? getDeviceClassOptions(domain, localize) : []
|
||||
);
|
||||
|
||||
private _availableOptions = memoizeOne(
|
||||
(options: PickerComboBoxItem[], selected: string[]) =>
|
||||
options.filter((option) => !selected.includes(option.id))
|
||||
);
|
||||
|
||||
private _getItems = () => {
|
||||
const options = this._options(this.domain, this._i18n?.localize);
|
||||
return this.multiple
|
||||
? this._availableOptions(options, this._value)
|
||||
: options;
|
||||
};
|
||||
|
||||
private _valueRenderer = (value: string) =>
|
||||
html`<span slot="headline">${this._deviceClassName(value)}</span>`;
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return this._i18n
|
||||
? this._i18n.localize("ui.components.device-class-picker.no_match", {
|
||||
term,
|
||||
})
|
||||
: html`No device classes found for ${term}`;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const localize = this._i18n?.localize;
|
||||
const emptyLabel = localize?.(
|
||||
"ui.components.device-class-picker.no_device_classes"
|
||||
);
|
||||
|
||||
if (this.multiple) {
|
||||
const value = this._value;
|
||||
return html`
|
||||
${
|
||||
value.length
|
||||
? html`
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
value,
|
||||
(deviceClass) => deviceClass,
|
||||
(deviceClass) => {
|
||||
const label = this._deviceClassName(deviceClass);
|
||||
return html`
|
||||
<ha-input-chip
|
||||
.item=${deviceClass}
|
||||
.label=${label}
|
||||
.disabled=${this.disabled}
|
||||
@remove=${this._removeItem}
|
||||
selected
|
||||
>
|
||||
${label}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-generic-picker
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${""}
|
||||
.addButtonLabel=${
|
||||
this.label ?? localize?.("ui.components.device-class-picker.add")
|
||||
}
|
||||
.getItems=${this._getItems}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${emptyLabel}
|
||||
@value-changed=${this._itemAdded}
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.label=${
|
||||
this.label ??
|
||||
localize?.("ui.components.device-class-picker.device_class")
|
||||
}
|
||||
.value=${this.value as string | undefined}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.getItems=${this._getItems}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${emptyLabel}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value || undefined });
|
||||
}
|
||||
|
||||
private _itemAdded(ev: ValueChangedEvent<string | undefined>) {
|
||||
ev.stopPropagation();
|
||||
const deviceClass = ev.detail.value;
|
||||
if (!deviceClass || this._value.includes(deviceClass)) {
|
||||
return;
|
||||
}
|
||||
this._setValue([...this._value, deviceClass]);
|
||||
}
|
||||
|
||||
private _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const deviceClass = (ev.currentTarget as HTMLElement & { item: string })
|
||||
.item;
|
||||
this._setValue(this._value.filter((item) => item !== deviceClass));
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
ha-generic-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
ha-chip-set {
|
||||
padding: 8px 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-device-class-picker": HaDeviceClassPicker;
|
||||
}
|
||||
}
|
||||
@@ -111,11 +111,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
|
||||
|
||||
// Skip the "unknown value" highlight and note for a value that is not in the
|
||||
// list but that the value renderer presents on its own.
|
||||
@property({ type: Boolean, attribute: "no-unknown-state" })
|
||||
public noUnknownState = false;
|
||||
|
||||
@query(".container") private _containerElement?: HTMLDivElement;
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
@@ -153,10 +148,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
private _unsubscribeTinyKeys?: () => void;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
changedProperties.has("value") ||
|
||||
changedProperties.has("noUnknownState")
|
||||
) {
|
||||
if (changedProperties.has("value")) {
|
||||
this._setUnknownValue();
|
||||
}
|
||||
}
|
||||
@@ -295,7 +287,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
private _setUnknownValue = () => {
|
||||
const items = this.getItems();
|
||||
if (
|
||||
this.noUnknownState ||
|
||||
this.allowCustomValue ||
|
||||
this.value === undefined ||
|
||||
this.value === null ||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { DeviceClassSelector } from "../../data/selector";
|
||||
import "../ha-device-class-picker";
|
||||
|
||||
@customElement("ha-selector-device_class")
|
||||
export class HaDeviceClassSelector extends LitElement {
|
||||
@property({ attribute: false }) public selector!: DeviceClassSelector;
|
||||
|
||||
@property() public value?: string | string[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-device-class-picker
|
||||
.domain=${this.selector.device_class?.domain}
|
||||
.value=${this.value}
|
||||
.multiple=${this.selector.device_class?.multiple ?? false}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-device-class-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-device-class-picker {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-device_class": HaDeviceClassSelector;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ const LOAD_ELEMENTS = {
|
||||
date: () => import("./ha-selector-date"),
|
||||
datetime: () => import("./ha-selector-datetime"),
|
||||
device: () => import("./ha-selector-device"),
|
||||
device_class: () => import("./ha-selector-device-class"),
|
||||
duration: () => import("./ha-selector-duration"),
|
||||
entity: () => import("./ha-selector-entity"),
|
||||
entity_name: () => import("./ha-selector-entity-name"),
|
||||
|
||||
@@ -25,7 +25,6 @@ import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
|
||||
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
|
||||
import type { MapBaseLayer } from "../../common/map/base-layer";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
@@ -161,8 +160,6 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private Leaflet?: LeafletModuleType;
|
||||
|
||||
private _baseLayer?: MapBaseLayer;
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _mapItems: (Marker | Circle)[] = [];
|
||||
@@ -214,7 +211,6 @@ export class HaMap extends ReactiveElement {
|
||||
this.leafletMap.remove();
|
||||
this.leafletMap = undefined;
|
||||
this.Leaflet = undefined;
|
||||
this._baseLayer = undefined;
|
||||
}
|
||||
|
||||
// the control went away with the map, so don't hold on to it
|
||||
@@ -312,7 +308,6 @@ export class HaMap extends ReactiveElement {
|
||||
map.classList.toggle("dark", this._darkMode);
|
||||
map.classList.toggle("forced-dark", this.themeMode === "dark");
|
||||
map.classList.toggle("forced-light", this.themeMode === "light");
|
||||
this._baseLayer?.setDarkMode(this._darkMode);
|
||||
}
|
||||
|
||||
private _loading = false;
|
||||
@@ -327,23 +322,11 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
this._loading = true;
|
||||
try {
|
||||
const setup = await setupLeafletMap(map, {
|
||||
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
|
||||
latitude: this._config?.latitude ?? 52.3731339,
|
||||
longitude: this._config?.longitude ?? 4.8903147,
|
||||
zoom: this.zoom,
|
||||
darkMode: this._darkMode,
|
||||
});
|
||||
// Setting up fetches a style, so the element can be gone by now.
|
||||
// `disconnectedCallback` had no map to tear down, and keeping this one
|
||||
// would leave a live map - and its WebGL context - on a detached host,
|
||||
// and its container too initialized to set up again on reconnect.
|
||||
if (!this.isConnected) {
|
||||
setup.map.remove();
|
||||
return;
|
||||
}
|
||||
this.leafletMap = setup.map;
|
||||
this.Leaflet = setup.leaflet;
|
||||
this._baseLayer = setup.baseLayer;
|
||||
this._updateMapStyle();
|
||||
this.leafletMap.on("click", (ev) => {
|
||||
if (this._clickCount === 0) {
|
||||
@@ -908,22 +891,9 @@ export class HaMap extends ReactiveElement {
|
||||
cursor: -moz-grabbing;
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
/* Only the raster fallback is inverted for dark mode, the vector style
|
||||
ships its own dark cartography. */
|
||||
.leaflet-tile-pane .leaflet-tile {
|
||||
.leaflet-tile-pane {
|
||||
filter: var(--map-filter);
|
||||
}
|
||||
/* The only two rules the MapLibre canvas needs from its stylesheet, the
|
||||
rest of it styles controls and popups we do not render. */
|
||||
.maplibregl-map {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.maplibregl-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.dark .leaflet-bar a {
|
||||
background-color: #1c1c1c;
|
||||
color: #ffffff;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
mdiMinusBox,
|
||||
mdiSwapHorizontal,
|
||||
mdiTextureBox,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
@@ -178,51 +177,36 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: canMigrate
|
||||
? html`<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
|
||||
: this.type === "entity"
|
||||
? html`
|
||||
<ha-state-icon
|
||||
.stateObj=${
|
||||
stateObject ||
|
||||
({
|
||||
entity_id: this.itemId,
|
||||
attributes: {},
|
||||
} as HassEntity)
|
||||
}
|
||||
>
|
||||
</ha-state-icon>
|
||||
`
|
||||
: nothing
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
|
||||
: this.type === "entity"
|
||||
? html`
|
||||
<ha-state-icon
|
||||
.stateObj=${
|
||||
stateObject ||
|
||||
({
|
||||
entity_id: this.itemId,
|
||||
attributes: {},
|
||||
} as HassEntity)
|
||||
}
|
||||
>
|
||||
</ha-state-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
|
||||
<span slot="headline"
|
||||
>${
|
||||
canMigrate
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced_headline"
|
||||
)
|
||||
: name
|
||||
}</span
|
||||
>
|
||||
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
|
||||
${
|
||||
notFound || (context && !this.hideContext)
|
||||
? html`<span slot="supporting-text"
|
||||
>${
|
||||
notFound
|
||||
? canMigrate
|
||||
? replacement!.candidates.length === 1 && replacement!.name
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced_by_one",
|
||||
{ device: replacement!.name }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced",
|
||||
{ count: replacement!.candidates.length }
|
||||
)
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced",
|
||||
{ count: replacement!.candidates.length }
|
||||
)
|
||||
: this.hass.localize(
|
||||
`ui.components.target-picker.${this.type}_not_found`
|
||||
)
|
||||
@@ -272,7 +256,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
@click=${this._migrate}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.replace_update"
|
||||
"ui.components.target-picker.replace_device"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { dump } from "js-yaml";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -8,16 +7,8 @@ import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_tim
|
||||
import type { Trigger } from "../../data/automation";
|
||||
import { migrateAutomationTrigger } from "../../data/automation";
|
||||
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
|
||||
import type { ConditionDescriptions } from "../../data/condition";
|
||||
import {
|
||||
conditionDescriptionsContext,
|
||||
fullEntitiesContext,
|
||||
labelsContext,
|
||||
manifestsContext,
|
||||
triggerDescriptionsContext,
|
||||
} from "../../data/context";
|
||||
import { fullEntitiesContext, labelsContext } from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { DomainManifestLookup } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { describeAction } from "../../data/script_i18n";
|
||||
@@ -26,12 +17,7 @@ import type {
|
||||
ChooseActionTraceStep,
|
||||
TraceExtended,
|
||||
} from "../../data/trace";
|
||||
import type { TargetSelector } from "../../data/selector";
|
||||
import { getDataFromPath, isTriggerPath } from "../../data/trace";
|
||||
import type { TriggerDescriptions } from "../../data/trigger";
|
||||
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
|
||||
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
|
||||
import "../../panels/config/automation/target/ha-automation-row-targets";
|
||||
import "../../panels/logbook/ha-logbook-renderer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
@@ -81,18 +67,6 @@ export class HaTracePathDetails extends LitElement {
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelReg!: LabelRegistryEntry[];
|
||||
|
||||
@state()
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
private _manifests?: DomainManifestLookup;
|
||||
|
||||
@state()
|
||||
@consume({ context: triggerDescriptionsContext, subscribe: true })
|
||||
private _triggerDescriptions?: TriggerDescriptions;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions?: ConditionDescriptions;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="padded-box trace-info">
|
||||
@@ -217,8 +191,51 @@ export class HaTracePathDetails extends LitElement {
|
||||
)}`;
|
||||
}
|
||||
|
||||
const selectedType = this.selected.type;
|
||||
|
||||
return html`
|
||||
${this._renderStepHeading(curPath, currentDetail, pathParts)}
|
||||
${
|
||||
curPath === this.selected.path
|
||||
? currentDetail.alias
|
||||
? html`<h2>${currentDetail.alias}</h2>`
|
||||
: selectedType === "trigger"
|
||||
? html`<h2>
|
||||
${describeTrigger(
|
||||
migrateAutomationTrigger({
|
||||
...currentDetail,
|
||||
}) as Trigger,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}
|
||||
</h2>`
|
||||
: selectedType === "condition"
|
||||
? html`<h2>
|
||||
${describeCondition(
|
||||
currentDetail,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}
|
||||
</h2>`
|
||||
: selectedType === "action"
|
||||
? html`<h2>
|
||||
${describeAction(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
currentDetail
|
||||
)}
|
||||
</h2>`
|
||||
: selectedType === "chooseOption"
|
||||
? html`<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.choose.option",
|
||||
{ number: pathParts[pathParts.length - 1] }
|
||||
)}
|
||||
</h2>`
|
||||
: nothing
|
||||
: html`<h2>
|
||||
${curPath.substring(this.selected.path.length + 1)}
|
||||
</h2>`
|
||||
}
|
||||
${
|
||||
data.length === 1
|
||||
? nothing
|
||||
@@ -229,7 +246,17 @@ export class HaTracePathDetails extends LitElement {
|
||||
)}
|
||||
</h3>`
|
||||
}
|
||||
${this._renderNestedCondition(curPath, currentDetail)}
|
||||
${
|
||||
curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
.includes("condition")
|
||||
? html`[${describeCondition(
|
||||
currentDetail,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}]<br />`
|
||||
: nothing
|
||||
}
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.executed",
|
||||
{
|
||||
@@ -297,130 +324,6 @@ export class HaTracePathDetails extends LitElement {
|
||||
return parts;
|
||||
}
|
||||
|
||||
private _renderStepHeading(
|
||||
curPath: string,
|
||||
currentDetail: any,
|
||||
pathParts: string[]
|
||||
) {
|
||||
if (curPath !== this.selected.path) {
|
||||
return html`<div class="heading">
|
||||
<h2>${curPath.substring(this.selected.path.length + 1)}</h2>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const selectedType = this.selected.type;
|
||||
|
||||
const description = currentDetail.alias
|
||||
? currentDetail.alias
|
||||
: selectedType === "trigger"
|
||||
? describeTrigger(
|
||||
migrateAutomationTrigger({ ...currentDetail }) as Trigger,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)
|
||||
: selectedType === "condition"
|
||||
? describeCondition(currentDetail, this.hass, this._entityReg)
|
||||
: selectedType === "action"
|
||||
? describeAction(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
currentDetail,
|
||||
undefined,
|
||||
false,
|
||||
this._manifests
|
||||
)
|
||||
: selectedType === "chooseOption"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.choose.option",
|
||||
{ number: pathParts[pathParts.length - 1] }
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (description === undefined) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="heading">
|
||||
<h2>${description}</h2>
|
||||
${this._renderTargets(currentDetail, selectedType)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderNestedCondition(curPath: string, currentDetail: any) {
|
||||
if (
|
||||
!curPath.substring(this.selected.path.length + 1).includes("condition")
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="nested-condition">
|
||||
${describeCondition(currentDetail, this.hass, this._entityReg)}
|
||||
${this._renderTargets(currentDetail, "condition", "s")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderTargets(
|
||||
config: any,
|
||||
type: NodeInfo["type"],
|
||||
size: "s" | "m" = "m"
|
||||
) {
|
||||
const target = this._getTarget(config, type);
|
||||
if (!target) {
|
||||
return nothing;
|
||||
}
|
||||
const targetSpec = this._getTargetSelector(config, type);
|
||||
return html`<div class="targets">
|
||||
<ha-automation-row-targets
|
||||
.target=${target}
|
||||
.selector=${targetSpec ? { target: targetSpec } : undefined}
|
||||
.size=${size}
|
||||
interactive
|
||||
></ha-automation-row-targets>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _getTargetSelector(
|
||||
config: any,
|
||||
type: NodeInfo["type"]
|
||||
): TargetSelector["target"] | undefined {
|
||||
if (type === "trigger") {
|
||||
return this._triggerDescriptions?.[config.trigger]?.target;
|
||||
}
|
||||
if (type === "condition") {
|
||||
return this._conditionDescriptions?.[config.condition]?.target;
|
||||
}
|
||||
if (type === "action" && typeof config.action === "string") {
|
||||
const [domain, service] = config.action.split(".", 2);
|
||||
return this.hass.services?.[domain]?.[service]?.target;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getTarget(
|
||||
config: any,
|
||||
type: NodeInfo["type"]
|
||||
): HassServiceTarget | undefined {
|
||||
if (config.target) {
|
||||
return config.target;
|
||||
}
|
||||
if (type === "trigger" || type === "condition") {
|
||||
const element = type === "trigger" ? config.trigger : config.condition;
|
||||
if (element === "state" || element === "numeric_state") {
|
||||
return getEntityTarget(config.entity_id);
|
||||
}
|
||||
if (element === "device") {
|
||||
return getDeviceTarget(config.device_id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (type === "action") {
|
||||
return config.entity_id
|
||||
? getEntityTarget(config.entity_id)
|
||||
: getDeviceTarget(config.device_id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _renderSelectedConfig() {
|
||||
if (!this.selected?.path) {
|
||||
return nothing;
|
||||
@@ -560,42 +463,6 @@ export class HaTracePathDetails extends LitElement {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
margin: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.heading .targets {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.targets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
margin-top: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.nested-condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.nested-condition .targets {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -480,40 +480,6 @@ export const migrateAutomationConfig = <
|
||||
return config;
|
||||
};
|
||||
|
||||
// The fields of the row holding a trigger, condition or action, as opposed to
|
||||
// the configuration of its type. The UI editors of some types build their value
|
||||
// from scratch, so these have to be carried over explicitly.
|
||||
export const TRIGGER_ROW_CONFIG_KEYS = [
|
||||
"alias",
|
||||
"note",
|
||||
"id",
|
||||
"enabled",
|
||||
"variables",
|
||||
] as const;
|
||||
|
||||
export const CONDITION_ROW_CONFIG_KEYS = ["alias", "note", "enabled"] as const;
|
||||
|
||||
export const ACTION_ROW_CONFIG_KEYS = [
|
||||
"alias",
|
||||
"note",
|
||||
"enabled",
|
||||
"continue_on_error",
|
||||
] as const;
|
||||
|
||||
export const pickRowConfig = <T extends object>(
|
||||
row: T,
|
||||
keys: readonly string[]
|
||||
): Partial<T> => {
|
||||
const source = row as Record<string, unknown>;
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (key in source) {
|
||||
config[key] = source[key];
|
||||
}
|
||||
}
|
||||
return config as Partial<T>;
|
||||
};
|
||||
|
||||
export const migrateAutomationTrigger = (
|
||||
trigger: Trigger | Trigger[],
|
||||
report?: AutomationMigrationReport
|
||||
|
||||
+221
-33
@@ -149,6 +149,9 @@ const formatNumericLimitValue = (
|
||||
export interface DescribeOptions {
|
||||
// Skip the user defined alias and describe the underlying config.
|
||||
ignoreAlias?: boolean;
|
||||
// Leave the entities out of the sentence, for rows that render them as
|
||||
// target badges.
|
||||
hideEntities?: boolean;
|
||||
}
|
||||
|
||||
export const describeTrigger = (
|
||||
@@ -207,7 +210,8 @@ const tryDescribeTrigger = (
|
||||
const description = describeLegacyTrigger(
|
||||
trigger as LegacyTrigger,
|
||||
hass,
|
||||
entityRegistry
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -231,7 +235,8 @@ const tryDescribeTrigger = (
|
||||
const describeLegacyTrigger = (
|
||||
trigger: LegacyTrigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
) => {
|
||||
// Event Trigger
|
||||
if (trigger.trigger === "event" && trigger.event_type) {
|
||||
@@ -262,7 +267,12 @@ const describeLegacyTrigger = (
|
||||
}
|
||||
|
||||
// Numeric State Trigger
|
||||
if (trigger.trigger === "numeric_state") {
|
||||
if (
|
||||
trigger.trigger === "numeric_state" &&
|
||||
(trigger.entity_id || hideEntities)
|
||||
) {
|
||||
const states = hass.states;
|
||||
|
||||
const stateObj = Array.isArray(trigger.entity_id)
|
||||
? hass.states[trigger.entity_id[0]]
|
||||
: (hass.states[trigger.entity_id] as HassEntity | undefined);
|
||||
@@ -282,23 +292,82 @@ const describeLegacyTrigger = (
|
||||
? describeDuration(hass.locale, trigger.for)
|
||||
: undefined;
|
||||
|
||||
const suffix = numericThresholdSuffix(trigger);
|
||||
if (!suffix) {
|
||||
return hass.localize(`${triggerTranslationBaseKey}.numeric_state.label`);
|
||||
}
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
|
||||
{
|
||||
attribute: attribute,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(trigger);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
}
|
||||
);
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
|
||||
{
|
||||
attribute: attribute,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(trigger.entity_id)) {
|
||||
for (const entity of trigger.entity_id.values()) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (trigger.entity_id) {
|
||||
entities.push(
|
||||
states[trigger.entity_id]
|
||||
? computeStateName(states[trigger.entity_id])
|
||||
: trigger.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
if (trigger.above !== undefined && trigger.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
{
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (trigger.above !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.above`,
|
||||
{
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (trigger.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.below`,
|
||||
{
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// State Trigger
|
||||
if (trigger.trigger === "state") {
|
||||
const states = hass.states;
|
||||
|
||||
const entityArray: string[] = ensureArray(trigger.entity_id);
|
||||
|
||||
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
|
||||
@@ -394,12 +463,39 @@ const describeLegacyTrigger = (
|
||||
duration = describeDuration(hass.locale, trigger.for) ?? "";
|
||||
}
|
||||
|
||||
if (hideEntities) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.changed`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
anyChange: toChoice === "special" ? "true" : "false",
|
||||
fromChoice: fromChoice,
|
||||
fromString: fromString,
|
||||
toChoice: toChoice,
|
||||
toString: toString,
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (entityArray) {
|
||||
for (const entity of entityArray) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.changed`,
|
||||
`${triggerTranslationBaseKey}.state.description.full`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
anyChange: toChoice === "special" ? "true" : "false",
|
||||
hasEntity: entities.length !== 0 ? "true" : "false",
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
fromChoice: fromChoice,
|
||||
fromString: fromString,
|
||||
toChoice: toChoice,
|
||||
@@ -941,7 +1037,8 @@ const tryDescribeCondition = (
|
||||
const description = describeLegacyCondition(
|
||||
condition as LegacyCondition,
|
||||
hass,
|
||||
entityRegistry
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -967,7 +1064,8 @@ const tryDescribeCondition = (
|
||||
const describeLegacyCondition = (
|
||||
condition: LegacyCondition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
) => {
|
||||
if (condition.condition === "or") {
|
||||
const conditions = ensureArray(condition.conditions);
|
||||
@@ -1024,6 +1122,12 @@ const describeLegacyCondition = (
|
||||
|
||||
// State Condition
|
||||
if (condition.condition === "state") {
|
||||
if (!condition.entity_id && !hideEntities) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.no_entity`
|
||||
);
|
||||
}
|
||||
|
||||
const stateObj = hass.states[
|
||||
Array.isArray(condition.entity_id)
|
||||
? condition.entity_id[0]
|
||||
@@ -1080,14 +1184,51 @@ const describeLegacyCondition = (
|
||||
duration = describeDuration(hass.locale, condition.for) || "";
|
||||
}
|
||||
|
||||
if (states.length === 0) {
|
||||
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
|
||||
if (hideEntities) {
|
||||
if (states.length === 0) {
|
||||
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.is`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
states: formatListWithOrs(hass.locale, states),
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(condition.entity_id)) {
|
||||
for (const entity of condition.entity_id.values()) {
|
||||
if (hass.states[entity]) {
|
||||
entities.push(computeStateName(hass.states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (condition.entity_id) {
|
||||
entities.push(
|
||||
hass.states[condition.entity_id]
|
||||
? computeStateName(hass.states[condition.entity_id])
|
||||
: condition.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.is`,
|
||||
`${conditionsTranslationBaseKey}.state.description.full`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
numberOfEntities: entities.length,
|
||||
// With "any", entities are joined with "or", which takes a singular
|
||||
// verb in English even for multiple entities ("A or B is ...").
|
||||
matchAny: condition.match === "any" ? "true" : "false",
|
||||
entities:
|
||||
condition.match === "any"
|
||||
? formatListWithOrs(hass.locale, entities)
|
||||
: formatListWithAnds(hass.locale, entities),
|
||||
numberOfStates: states.length,
|
||||
states: formatListWithOrs(hass.locale, states),
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
@@ -1096,7 +1237,10 @@ const describeLegacyCondition = (
|
||||
}
|
||||
|
||||
// Numeric State Condition
|
||||
if (condition.condition === "numeric_state") {
|
||||
if (
|
||||
condition.condition === "numeric_state" &&
|
||||
(condition.entity_id || hideEntities)
|
||||
) {
|
||||
const entity_ids = condition.entity_id
|
||||
? ensureArray(condition.entity_id)
|
||||
: [];
|
||||
@@ -1113,20 +1257,64 @@ const describeLegacyCondition = (
|
||||
: condition.attribute
|
||||
: undefined;
|
||||
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
|
||||
{
|
||||
attribute,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
|
||||
{
|
||||
attribute,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
|
||||
const entity = formatListWithAnds(
|
||||
hass.locale,
|
||||
entity_ids.map((id) =>
|
||||
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
|
||||
)
|
||||
);
|
||||
|
||||
if (condition.above !== undefined && condition.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
{
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
if (condition.above !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.above`,
|
||||
{
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
}
|
||||
);
|
||||
}
|
||||
if (condition.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.below`,
|
||||
{
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Time condition
|
||||
|
||||
+5
-38
@@ -3,16 +3,10 @@ import type { HomeAssistant } from "../types";
|
||||
|
||||
type StrictConnectionMode = "disabled" | "guard_page" | "drop_connection";
|
||||
|
||||
export interface CloudAutoLogin {
|
||||
email: string;
|
||||
failed: string | null;
|
||||
}
|
||||
|
||||
interface CloudStatusNotLoggedIn {
|
||||
logged_in: false;
|
||||
cloud: "disconnected" | "connecting" | "connected";
|
||||
http_use_ssl: boolean;
|
||||
auto_login: CloudAutoLogin | null;
|
||||
}
|
||||
|
||||
export interface CertificateInformation {
|
||||
@@ -108,15 +102,15 @@ export interface CloudLoginMFA extends CloudLoginBase {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export type CloudEvent =
|
||||
| { type: "login" | "logout" | "auto_login_cancelled" }
|
||||
| { type: "auto_login_failed"; translation_key: string };
|
||||
|
||||
export const cloudLogin = ({
|
||||
hass,
|
||||
...rest
|
||||
}: CloudLoginPassword | CloudLoginMFA) =>
|
||||
hass.callApi<{ success: boolean }>("POST", "cloud/login", rest);
|
||||
hass.callApi<{ success: boolean; cloud_pipeline?: string }>(
|
||||
"POST",
|
||||
"cloud/login",
|
||||
rest
|
||||
);
|
||||
|
||||
export const cloudLogout = (hass: HomeAssistant) =>
|
||||
hass.callApi("POST", "cloud/logout");
|
||||
@@ -136,33 +130,6 @@ export const cloudRegister = (
|
||||
password,
|
||||
});
|
||||
|
||||
export const cloudRegisterAutoLogin = (
|
||||
hass: HomeAssistant,
|
||||
email: string,
|
||||
password: string
|
||||
) =>
|
||||
hass.callApi("POST", "cloud/register_auto_login", {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
export const attemptCloudAutoLoginNow = (hass: HomeAssistant) =>
|
||||
hass.callWS({ type: "cloud/attempt_auto_login_now" });
|
||||
|
||||
export const resendCloudAutoLoginConfirm = (hass: HomeAssistant) =>
|
||||
hass.callWS({ type: "cloud/resend_auto_login_confirm" });
|
||||
|
||||
export const cancelCloudAutoLogin = (hass: HomeAssistant) =>
|
||||
hass.callWS({ type: "cloud/cancel_auto_login" });
|
||||
|
||||
export const subscribeCloudEvents = (
|
||||
hass: HomeAssistant,
|
||||
callback: (event: CloudEvent) => void
|
||||
) =>
|
||||
hass.connection.subscribeMessage<CloudEvent>(callback, {
|
||||
type: "cloud/subscribe_events",
|
||||
});
|
||||
|
||||
export const cloudResendVerification = (hass: HomeAssistant, email: string) =>
|
||||
hass.callApi("POST", "cloud/resend_confirm", {
|
||||
email,
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface ConfigEntry {
|
||||
pref_disable_polling: boolean;
|
||||
disabled_by: "user" | null;
|
||||
reason: string | null;
|
||||
error_reason_translation_domain: string | null;
|
||||
error_reason_translation_key: string | null;
|
||||
error_reason_translation_placeholders: Record<string, string> | null;
|
||||
}
|
||||
|
||||
@@ -182,69 +182,26 @@ export const deviceAutomationEditorMode = (
|
||||
: "unknown-device";
|
||||
};
|
||||
|
||||
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
|
||||
deviceAutomationIdentifiers
|
||||
.filter((property) => property !== "device_id" && property !== "entity_id")
|
||||
.every((property) => Object.is(a[property], b[property]));
|
||||
|
||||
// An entity can be referenced by its registry id or by its entity id, and the
|
||||
// two sides do not have to agree.
|
||||
const deviceAutomationsSameEntity = (
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
|
||||
// automation can be matched to the equivalent one on a different device (for
|
||||
// example when a referenced device was replaced by a split device).
|
||||
export const deviceAutomationsSimilar = (
|
||||
a: DeviceAutomation,
|
||||
b: DeviceAutomation
|
||||
) => {
|
||||
if (!a.entity_id && !b.entity_id) {
|
||||
return true;
|
||||
}
|
||||
if (!a.entity_id || !b.entity_id) {
|
||||
if (typeof a !== typeof b) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
a.entity_id === b.entity_id ||
|
||||
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
|
||||
);
|
||||
};
|
||||
|
||||
// A device exposes the same automation type once per entity, so the same type
|
||||
// on another entity is a different automation, not an equivalent one.
|
||||
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automations: T[],
|
||||
automation: DeviceAutomation
|
||||
): T | undefined =>
|
||||
automations.find(
|
||||
(candidate) =>
|
||||
deviceAutomationsSameType(candidate, automation) &&
|
||||
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
|
||||
);
|
||||
|
||||
// Among the split devices that replaced a removed device, the ones that offer
|
||||
// the given automation. Nothing in the registry says which of them took it over,
|
||||
// so each candidate has to be asked.
|
||||
export const fetchReplacementDevices = async <T extends DeviceAutomation>(
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automation: DeviceAutomation,
|
||||
compositeSplits: DeviceCompositeSplits,
|
||||
fetchDeviceAutomations: (callWS: CallWS, deviceId: string) => Promise<T[]>
|
||||
): Promise<string[]> => {
|
||||
const candidates =
|
||||
compositeSplits[automation.device_id]?.split_ids.filter(
|
||||
(id) => id in hass.devices
|
||||
) ?? [];
|
||||
const automationsPerCandidate = await Promise.all(
|
||||
candidates.map((id) =>
|
||||
fetchDeviceAutomations(hass.callWS, id).catch(() => [] as T[])
|
||||
)
|
||||
);
|
||||
return candidates.filter((_id, index) =>
|
||||
findEquivalentDeviceAutomation(
|
||||
entityRegistry,
|
||||
automationsPerCandidate[index],
|
||||
automation
|
||||
)
|
||||
);
|
||||
return deviceAutomationIdentifiers
|
||||
.filter((property) => property !== "device_id" && property !== "entity_id")
|
||||
.every((property) => {
|
||||
const inA = property in a;
|
||||
const inB = property in b;
|
||||
if (!inA && !inB) {
|
||||
return true;
|
||||
}
|
||||
return Object.is(a[property], b[property]);
|
||||
});
|
||||
};
|
||||
|
||||
const compareEntityIdWithEntityRegId = (
|
||||
|
||||
@@ -1,7 +1,71 @@
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../sensor_entity_constants";
|
||||
|
||||
export const NO_DEVICE_CLASS = "none";
|
||||
|
||||
// Mirrors core's `<Domain>DeviceClass` enums, until the backend exposes them.
|
||||
export const DOMAIN_DEVICE_CLASSES: Record<string, readonly string[]> = {
|
||||
binary_sensor: [
|
||||
"battery",
|
||||
"battery_charging",
|
||||
"carbon_monoxide",
|
||||
"cold",
|
||||
"connectivity",
|
||||
"door",
|
||||
"garage_door",
|
||||
"gas",
|
||||
"heat",
|
||||
"light",
|
||||
"lock",
|
||||
"moisture",
|
||||
"motion",
|
||||
"moving",
|
||||
"occupancy",
|
||||
"opening",
|
||||
"plug",
|
||||
"power",
|
||||
"presence",
|
||||
"problem",
|
||||
"running",
|
||||
"safety",
|
||||
"smoke",
|
||||
"sound",
|
||||
"tamper",
|
||||
"update",
|
||||
"vibration",
|
||||
"window",
|
||||
],
|
||||
button: ["identify", "restart", "update"],
|
||||
cover: [
|
||||
"awning",
|
||||
"blind",
|
||||
"curtain",
|
||||
"damper",
|
||||
"door",
|
||||
"garage",
|
||||
"gate",
|
||||
"shade",
|
||||
"shutter",
|
||||
"window",
|
||||
],
|
||||
event: ["button", "doorbell", "motion"],
|
||||
humidifier: ["dehumidifier", "humidifier"],
|
||||
image_processing: ["alpr", "face", "ocr"],
|
||||
infrared: ["emitter", "receiver"],
|
||||
media_player: ["projector", "receiver", "speaker", "tv"],
|
||||
number: SENSOR_NUMERIC_DEVICE_CLASSES,
|
||||
sensor: [
|
||||
...SENSOR_NUMERIC_DEVICE_CLASSES,
|
||||
"date",
|
||||
"enum",
|
||||
"timestamp",
|
||||
"uptime",
|
||||
],
|
||||
switch: ["outlet", "switch"],
|
||||
update: ["firmware"],
|
||||
valve: ["gas", "water"],
|
||||
};
|
||||
|
||||
export const computeDeviceClassName = (
|
||||
localize: LocalizeFunc,
|
||||
domain: string,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
|
||||
import { NO_DEVICE_CLASS } from "./device_class";
|
||||
import { DOMAIN_DEVICE_CLASSES, NO_DEVICE_CLASS } from "./device_class";
|
||||
|
||||
const SEPARATOR = "/";
|
||||
|
||||
|
||||
+34
-1
@@ -1,9 +1,33 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { CallWS, HomeAssistant } from "../types";
|
||||
|
||||
export interface ESPHomeEncryptionKey {
|
||||
encryption_key: string;
|
||||
}
|
||||
|
||||
export type ESPHomeSerialPortType = "TTL" | "RS232" | "RS485";
|
||||
|
||||
export interface ESPHomeBluetoothProxyCapabilities {
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
export interface ESPHomeZWaveProxyCapabilities {
|
||||
supported: boolean;
|
||||
home_id: number;
|
||||
}
|
||||
|
||||
export interface ESPHomeSerialProxy {
|
||||
name: string;
|
||||
port_type: ESPHomeSerialPortType | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ESPHomeDeviceCapabilities {
|
||||
available: boolean;
|
||||
bluetooth_proxy: ESPHomeBluetoothProxyCapabilities;
|
||||
zwave_proxy: ESPHomeZWaveProxyCapabilities;
|
||||
serial_proxies: ESPHomeSerialProxy[];
|
||||
}
|
||||
|
||||
export const fetchESPHomeEncryptionKey = (
|
||||
hass: HomeAssistant,
|
||||
entry_id: string
|
||||
@@ -12,3 +36,12 @@ export const fetchESPHomeEncryptionKey = (
|
||||
type: "esphome/get_encryption_key",
|
||||
entry_id,
|
||||
});
|
||||
|
||||
export const fetchESPHomeDeviceCapabilities = (
|
||||
hass: { callWS: CallWS },
|
||||
deviceId: string
|
||||
): Promise<ESPHomeDeviceCapabilities> =>
|
||||
hass.callWS({
|
||||
type: "esphome/get_device_capabilities",
|
||||
device_id: deviceId,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { ConfigEntry } from "./config_entries";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
import type { ESPHomeDeviceCapabilities, ESPHomeSerialProxy } from "./esphome";
|
||||
import type { ESPHomeFrontendUserData } from "./frontend";
|
||||
import type { SerialPortUsage } from "./usb";
|
||||
|
||||
export const MUSIC_ASSISTANT_ADDON_SLUG = "d5369777_music_assistant";
|
||||
|
||||
export const MUSIC_ASSISTANT_DOCS_URL = "https://music-assistant.io/";
|
||||
|
||||
export const ESPHOME_SERIAL_INTEGRATIONS = [
|
||||
"anthemav",
|
||||
"monoprice",
|
||||
"russound_rio",
|
||||
"jvc_projector",
|
||||
"elkm1",
|
||||
] as const;
|
||||
|
||||
export type ESPHomeSerialIntegrationDomain =
|
||||
(typeof ESPHOME_SERIAL_INTEGRATIONS)[number];
|
||||
|
||||
export type ESPHomeCapabilityId =
|
||||
"bluetooth" | "audio" | "connectivity" | "serial";
|
||||
|
||||
export type ESPHomeCapabilityStatus =
|
||||
"not-started" | "active" | "detected" | "completed";
|
||||
|
||||
export interface ESPHomeSetupStatus {
|
||||
bluetooth?: "completed";
|
||||
audio?: "active" | "completed";
|
||||
connectivity?: "not-started" | "detected" | "completed";
|
||||
serial?: "not-started" | "completed";
|
||||
}
|
||||
|
||||
export const CAPABILITY_ORDER: ESPHomeCapabilityId[] = [
|
||||
"bluetooth",
|
||||
"audio",
|
||||
"connectivity",
|
||||
"serial",
|
||||
];
|
||||
|
||||
/** Capability accents from the ESPHome device setup prototype. */
|
||||
export const ESPHOME_CAPABILITY_ACCENTS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: "#2962ff",
|
||||
audio: "#53c22b",
|
||||
connectivity: "#00acc1",
|
||||
serial: "#8353d1",
|
||||
};
|
||||
|
||||
export const deviceHasMediaPlayerEntity = (
|
||||
deviceId: string,
|
||||
entities: Iterable<{ entity_id: string; device_id?: string | null }>
|
||||
): boolean => {
|
||||
for (const entity of entities) {
|
||||
if (
|
||||
entity.device_id === deviceId &&
|
||||
computeDomain(entity.entity_id) === "media_player"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasESPHomeSetupCapabilities = (
|
||||
capabilities: ESPHomeDeviceCapabilities | undefined | null,
|
||||
options: { mediaPlayerSupported?: boolean } = {}
|
||||
): boolean =>
|
||||
Boolean(
|
||||
capabilities &&
|
||||
(capabilities.bluetooth_proxy.supported ||
|
||||
options.mediaPlayerSupported ||
|
||||
capabilities.zwave_proxy.supported ||
|
||||
capabilities.serial_proxies.length > 0)
|
||||
);
|
||||
|
||||
export const hasZWaveJSEntryForDevice = (
|
||||
deviceId: string,
|
||||
devices: Record<string, DeviceRegistryEntry>,
|
||||
entries: ConfigEntry[]
|
||||
): boolean => {
|
||||
const zwaveEntryIds = new Set(
|
||||
entries
|
||||
.filter((entry) => entry.domain === "zwave_js" && !entry.disabled_by)
|
||||
.map((entry) => entry.entry_id)
|
||||
);
|
||||
if (!zwaveEntryIds.size) {
|
||||
return false;
|
||||
}
|
||||
const device = devices[deviceId];
|
||||
if (device?.config_entries.some((entryId) => zwaveEntryIds.has(entryId))) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(devices).some(
|
||||
(candidate) =>
|
||||
candidate.via_device_id === deviceId &&
|
||||
candidate.config_entries.some((entryId) => zwaveEntryIds.has(entryId))
|
||||
);
|
||||
};
|
||||
|
||||
export const isESPHomeSerialConfigured = (
|
||||
serialProxies: readonly Pick<ESPHomeSerialProxy, "url">[],
|
||||
ports: readonly Pick<SerialPortUsage, "device" | "consumers">[]
|
||||
): boolean =>
|
||||
serialProxies.some((proxy) =>
|
||||
ports.some((port) => port.device === proxy.url && port.consumers.length > 0)
|
||||
);
|
||||
|
||||
export const deriveESPHomeSetupStatus = (
|
||||
capabilities: ESPHomeDeviceCapabilities,
|
||||
options: {
|
||||
mediaPlayerSupported: boolean;
|
||||
musicAssistantLoaded: boolean;
|
||||
zwaveJsEntryExists: boolean;
|
||||
serialConfigured?: boolean;
|
||||
}
|
||||
): ESPHomeSetupStatus => {
|
||||
const status: ESPHomeSetupStatus = {};
|
||||
|
||||
if (capabilities.bluetooth_proxy.supported) {
|
||||
status.bluetooth = "completed";
|
||||
}
|
||||
|
||||
if (options.mediaPlayerSupported) {
|
||||
status.audio = options.musicAssistantLoaded ? "completed" : "active";
|
||||
}
|
||||
|
||||
if (capabilities.zwave_proxy.supported) {
|
||||
if (options.zwaveJsEntryExists) {
|
||||
status.connectivity = "completed";
|
||||
} else if (capabilities.zwave_proxy.home_id !== 0) {
|
||||
status.connectivity = "detected";
|
||||
} else {
|
||||
status.connectivity = "not-started";
|
||||
}
|
||||
}
|
||||
|
||||
if (capabilities.serial_proxies.length > 0) {
|
||||
status.serial = options.serialConfigured ? "completed" : "not-started";
|
||||
}
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
export const getESPHomeSetupCapabilityIds = (
|
||||
status: ESPHomeSetupStatus
|
||||
): ESPHomeCapabilityId[] =>
|
||||
CAPABILITY_ORDER.filter((id) => status[id] !== undefined);
|
||||
|
||||
export const countRemainingESPHomeCapabilities = (
|
||||
status: ESPHomeSetupStatus
|
||||
): number =>
|
||||
getESPHomeSetupCapabilityIds(status).filter(
|
||||
(id) => status[id] !== "completed"
|
||||
).length;
|
||||
|
||||
export const hasStartedNonBluetoothESPHomeSetup = (
|
||||
status: ESPHomeSetupStatus
|
||||
): boolean =>
|
||||
status.audio === "completed" ||
|
||||
status.connectivity === "completed" ||
|
||||
status.serial === "completed";
|
||||
|
||||
export const isESPHomeSetupDeferred = (
|
||||
data: ESPHomeFrontendUserData | null | undefined,
|
||||
deviceId: string
|
||||
): boolean => Boolean(data?.setupDeferred?.includes(deviceId));
|
||||
|
||||
export const withDeferredESPHomeDevice = (
|
||||
data: ESPHomeFrontendUserData | null | undefined,
|
||||
deviceId: string
|
||||
): ESPHomeFrontendUserData => ({
|
||||
...data,
|
||||
setupDeferred: [...new Set([...(data?.setupDeferred ?? []), deviceId])],
|
||||
});
|
||||
+5
-13
@@ -18,6 +18,10 @@ export interface SidebarFrontendUserData {
|
||||
hiddenPanels?: string[];
|
||||
}
|
||||
|
||||
export interface ESPHomeFrontendUserData {
|
||||
setupDeferred?: string[];
|
||||
}
|
||||
|
||||
export interface CoreFrontendSystemData {
|
||||
default_panel?: string;
|
||||
onboarded_version?: string;
|
||||
@@ -35,18 +39,6 @@ export interface HomeFrontendSystemData {
|
||||
shortcuts?: ShortcutItem[];
|
||||
}
|
||||
|
||||
export type SecurityAlertSeverity = "alert" | "warning";
|
||||
|
||||
export interface SecurityAlertEntityConfig {
|
||||
entity: string;
|
||||
severity?: SecurityAlertSeverity;
|
||||
}
|
||||
|
||||
export interface SecurityFrontendSystemData {
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
}
|
||||
|
||||
export interface EnergyFrontendSystemData {
|
||||
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
|
||||
// hidden. An absent key or array means nothing is hidden (all cards visible),
|
||||
@@ -58,12 +50,12 @@ declare global {
|
||||
interface FrontendUserData {
|
||||
core: CoreFrontendUserData;
|
||||
sidebar: SidebarFrontendUserData;
|
||||
esphome: ESPHomeFrontendUserData;
|
||||
}
|
||||
interface FrontendSystemData {
|
||||
core: CoreFrontendSystemData;
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
|
||||
|
||||
@@ -8,7 +7,7 @@ export interface ResolvedMediaSource {
|
||||
}
|
||||
|
||||
export const resolveMediaSource = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
hass: HomeAssistant,
|
||||
media_content_id: string
|
||||
) =>
|
||||
hass.callWS<ResolvedMediaSource>({
|
||||
@@ -16,23 +15,6 @@ export const resolveMediaSource = (
|
||||
media_content_id,
|
||||
});
|
||||
|
||||
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
|
||||
// core). Resolving again returns a different signature, which would defeat the
|
||||
// browser cache, so reuse the resolved URL for just under its validity.
|
||||
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
|
||||
|
||||
export const resolveMediaSourceWithCache = (
|
||||
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
|
||||
media_content_id: string
|
||||
): Promise<ResolvedMediaSource> =>
|
||||
timeCacheEntityPromiseFunc(
|
||||
"_resolvedMediaSource",
|
||||
RESOLVE_CACHE_TIME,
|
||||
resolveMediaSource,
|
||||
hass,
|
||||
media_content_id
|
||||
);
|
||||
|
||||
export const browseLocalMediaPlayer = (
|
||||
hass: HomeAssistant,
|
||||
mediaContentId?: string
|
||||
|
||||
@@ -48,7 +48,6 @@ export type Selector =
|
||||
| DeviceSelector
|
||||
| FloorSelector
|
||||
| LegacyDeviceSelector
|
||||
| DeviceClassSelector
|
||||
| DurationSelector
|
||||
| EntitySelector
|
||||
| EntityNameSelector
|
||||
@@ -485,13 +484,6 @@ export interface SelectSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface DeviceClassSelector {
|
||||
device_class: {
|
||||
domain: string;
|
||||
multiple?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SelectorSelector {
|
||||
selector: {} | null;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { CallWS, HomeAssistant } from "../types";
|
||||
|
||||
export interface SerialPort {
|
||||
device: string;
|
||||
@@ -41,7 +41,7 @@ export const scanUSBDevices = (hass: HomeAssistant) =>
|
||||
export const listSerialPorts = (hass: HomeAssistant) =>
|
||||
hass.callWS<SerialPort[]>({ type: "usb/list_serial_ports" });
|
||||
|
||||
export const listSerialPortsWithUsage = (hass: HomeAssistant) =>
|
||||
export const listSerialPortsWithUsage = (hass: { callWS: CallWS }) =>
|
||||
hass.callWS<SerialPortUsage[]>({
|
||||
type: "usb/list_serial_ports",
|
||||
include_usage: true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ESPHomeDeviceCapabilities } from "../../data/esphome";
|
||||
|
||||
export const loadESPHomeDeviceSetupDialog = () =>
|
||||
import("./dialog-esphome-device-setup");
|
||||
|
||||
export interface ESPHomeDeviceSetupDialogParams {
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
capabilities?: ESPHomeDeviceCapabilities;
|
||||
mediaPlayerSupported?: boolean;
|
||||
dialogClosedCallback?: () => void;
|
||||
}
|
||||
|
||||
export const showESPHomeDeviceSetupDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: ESPHomeDeviceSetupDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-esphome-device-setup",
|
||||
dialogImport: loadESPHomeDeviceSetupDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -230,13 +230,13 @@ export class DialogForm
|
||||
): Promise<void> {
|
||||
await this._afterFormRender();
|
||||
|
||||
if (!this.isConnected || !this._open || this._params !== expectedParams) {
|
||||
if (!this._open || this._params !== expectedParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._waitForSelectorElements();
|
||||
|
||||
if (!this.isConnected || !this._open || this._params !== expectedParams) {
|
||||
if (!this._open || this._params !== expectedParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,12 +250,7 @@ export class DialogForm
|
||||
): Promise<void> {
|
||||
await this._afterFormRender();
|
||||
|
||||
if (
|
||||
!this.isConnected ||
|
||||
!this._open ||
|
||||
this._params !== expectedParams ||
|
||||
!this._dialog
|
||||
) {
|
||||
if (!this._open || this._params !== expectedParams || !this._dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { mdiCheck, mdiContentCopy, mdiDevices, mdiTextureBox } from "@mdi/js";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../../common/entity/compute_floor_name";
|
||||
@@ -13,16 +11,8 @@ import { getEntityContext } from "../../common/entity/context/get_entity_context
|
||||
import checkValidDate from "../../common/datetime/check_valid_date";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import "../../components/ha-attribute-value";
|
||||
import "../../components/ha-floor-icon";
|
||||
import "../../components/ha-icon";
|
||||
import "../../components/ha-icon-next";
|
||||
import "../../components/ha-label";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/item/ha-list-item-button";
|
||||
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
|
||||
import "../../components/item/ha-list-item-value";
|
||||
import "../../components/list/ha-grouped-list";
|
||||
import { copyToClipboard } from "../../common/util/copy-clipboard";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
@@ -35,8 +25,6 @@ import { getFeatures } from "../../common/entity/get_domain_features";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import { titleCase } from "../../common/string/title-case";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import { showToast } from "../../util/toast";
|
||||
|
||||
interface DetailsViewParams {
|
||||
entityId: string;
|
||||
@@ -45,10 +33,7 @@ interface DetailsViewParams {
|
||||
interface DetailEntry {
|
||||
translationKey: LocalizeKeys;
|
||||
value: string;
|
||||
displayValue?: TemplateResult;
|
||||
href?: string;
|
||||
icon?: TemplateResult;
|
||||
copyable?: boolean;
|
||||
}
|
||||
|
||||
@customElement("ha-more-info-details")
|
||||
@@ -67,17 +52,6 @@ class HaMoreInfoDetails extends LitElement {
|
||||
@state()
|
||||
private _labels?: LabelRegistryEntry[];
|
||||
|
||||
@state() private _copiedValue?: string;
|
||||
|
||||
private _copyFeedbackTimeout?: number;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.clearTimeout(this._copyFeedbackTimeout);
|
||||
this._copyFeedbackTimeout = undefined;
|
||||
this._copiedValue = undefined;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("entry") && this.entry) {
|
||||
@@ -119,12 +93,12 @@ class HaMoreInfoDetails extends LitElement {
|
||||
? this.hass.localize(`component.${this.entry.platform}.title`) ||
|
||||
this.entry.platform
|
||||
: undefined;
|
||||
const labels =
|
||||
this.entry?.labels.map((labelId) => ({
|
||||
id: labelId,
|
||||
entry: this._labels?.find((label) => label.label_id === labelId),
|
||||
})) ?? [];
|
||||
const labelNames = labels.map(({ id, entry }) => entry?.name ?? id);
|
||||
const labelNames =
|
||||
this.entry?.labels.map(
|
||||
(labelId) =>
|
||||
this._labels?.find((label) => label.label_id === labelId)?.name ??
|
||||
labelId
|
||||
) ?? [];
|
||||
const contextEntries: DetailEntry[] = [];
|
||||
|
||||
if (floor && floorName) {
|
||||
@@ -132,7 +106,6 @@ class HaMoreInfoDetails extends LitElement {
|
||||
translationKey: "ui.dialogs.more_info_control.floor",
|
||||
value: floorName,
|
||||
href: "/config/areas/dashboard",
|
||||
icon: html`<ha-floor-icon slot="end" .floor=${floor}></ha-floor-icon>`,
|
||||
});
|
||||
}
|
||||
if (area && areaName) {
|
||||
@@ -140,9 +113,6 @@ class HaMoreInfoDetails extends LitElement {
|
||||
translationKey: "ui.components.related-items.area",
|
||||
value: areaName,
|
||||
href: `/config/areas/area/${area.area_id}`,
|
||||
icon: area.icon
|
||||
? html`<ha-icon slot="end" .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
|
||||
});
|
||||
}
|
||||
if (device && deviceName) {
|
||||
@@ -150,7 +120,6 @@ class HaMoreInfoDetails extends LitElement {
|
||||
translationKey: "ui.components.related-items.device",
|
||||
value: deviceName,
|
||||
href: `/config/devices/device/${device.id}`,
|
||||
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
|
||||
});
|
||||
}
|
||||
if (this.entry?.platform && integrationName) {
|
||||
@@ -160,63 +129,31 @@ class HaMoreInfoDetails extends LitElement {
|
||||
href: this.entry.config_entry_id
|
||||
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
|
||||
: undefined,
|
||||
icon: html`<img
|
||||
slot="end"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: this.entry.platform,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`,
|
||||
});
|
||||
}
|
||||
|
||||
contextEntries.push(
|
||||
const entityEntries: DetailEntry[] = [
|
||||
{
|
||||
translationKey: "ui.dialogs.more_info_control.entity_id",
|
||||
value: this.params.entityId,
|
||||
copyable: true,
|
||||
},
|
||||
{
|
||||
translationKey: "ui.dialogs.more_info_control.labels",
|
||||
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
|
||||
displayValue: labels.length
|
||||
? html`<div class="labels">
|
||||
${labels.map(
|
||||
({ id, entry }) => html`
|
||||
<ha-label
|
||||
class="text-ellipsis"
|
||||
.color=${entry?.color ?? undefined}
|
||||
.description=${entry?.description ?? undefined}
|
||||
>
|
||||
${
|
||||
entry?.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${entry.icon}
|
||||
></ha-icon>`
|
||||
: nothing
|
||||
}
|
||||
${entry?.name ?? id}
|
||||
</ha-label>
|
||||
`
|
||||
)}
|
||||
</div>`
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
},
|
||||
];
|
||||
const yamlData = {
|
||||
context: {
|
||||
...(floorName ? { floor: floorName } : {}),
|
||||
...(areaName ? { area: areaName } : {}),
|
||||
...(deviceName ? { device: deviceName } : {}),
|
||||
...(integrationName ? { integration: integrationName } : {}),
|
||||
...(contextEntries.length
|
||||
? {
|
||||
context: {
|
||||
...(floorName ? { floor: floorName } : {}),
|
||||
...(areaName ? { area: areaName } : {}),
|
||||
...(deviceName ? { device: deviceName } : {}),
|
||||
...(integrationName ? { integration: integrationName } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
entity: {
|
||||
entity_id: this.params.entityId,
|
||||
labels: labelNames,
|
||||
},
|
||||
@@ -234,9 +171,17 @@ class HaMoreInfoDetails extends LitElement {
|
||||
in-dialog
|
||||
></ha-yaml-editor>`
|
||||
: html`
|
||||
<ha-grouped-list>
|
||||
${this._renderEntries(contextEntries)}
|
||||
</ha-grouped-list>
|
||||
${
|
||||
contextEntries.length
|
||||
? html`<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.context"
|
||||
)}
|
||||
>
|
||||
${this._renderEntries(contextEntries)}
|
||||
</ha-grouped-list>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
@@ -246,6 +191,14 @@ class HaMoreInfoDetails extends LitElement {
|
||||
${this._renderEntries(stateEntries)}
|
||||
</ha-grouped-list>
|
||||
|
||||
<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.entity"
|
||||
)}
|
||||
>
|
||||
${this._renderEntries(entityEntries)}
|
||||
</ha-grouped-list>
|
||||
|
||||
<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.attributes"
|
||||
@@ -331,72 +284,17 @@ class HaMoreInfoDetails extends LitElement {
|
||||
}
|
||||
|
||||
private _renderEntries(entries: DetailEntry[]) {
|
||||
return entries.map((entry) => {
|
||||
const label = this.hass.localize(entry.translationKey);
|
||||
|
||||
if (!entry.href && !entry.copyable) {
|
||||
return html`
|
||||
<ha-list-item-value .label=${label}
|
||||
>${entry.displayValue ?? entry.value}</ha-list-item-value
|
||||
>
|
||||
`;
|
||||
}
|
||||
|
||||
if (entry.copyable) {
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.copy_value",
|
||||
{ label, value: entry.value }
|
||||
)}
|
||||
data-value=${entry.value}
|
||||
@click=${this._copyValue}
|
||||
>
|
||||
<div class="link-row" slot="content">
|
||||
<div class="label">${label}</div>
|
||||
<div class="value">${entry.value}</div>
|
||||
</div>
|
||||
<ha-svg-icon
|
||||
class=${this._copiedValue === entry.value ? "copy-success" : ""}
|
||||
slot="end"
|
||||
.path=${
|
||||
this._copiedValue === entry.value ? mdiCheck : mdiContentCopy
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-list-item-button .href=${entry.href}>
|
||||
<div class="link-row" slot="content">
|
||||
<div class="label">${label}</div>
|
||||
<div class="value">${entry.value}</div>
|
||||
</div>
|
||||
${entry.icon ?? nothing}
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
private async _copyValue(ev: HASSDomCurrentTargetEvent<HaListItemButton>) {
|
||||
const value = ev.currentTarget.dataset.value;
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
await copyToClipboard(value);
|
||||
const duration = 4000;
|
||||
this._copiedValue = value;
|
||||
window.clearTimeout(this._copyFeedbackTimeout);
|
||||
this._copyFeedbackTimeout = window.setTimeout(() => {
|
||||
this._copiedValue = undefined;
|
||||
this._copyFeedbackTimeout = undefined;
|
||||
}, duration);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||
duration,
|
||||
});
|
||||
return entries.map(
|
||||
(entry) => html`
|
||||
<ha-list-item-value .label=${this.hass.localize(entry.translationKey)}>
|
||||
${
|
||||
entry.href
|
||||
? html`<a href=${entry.href}>${entry.value}</a>`
|
||||
: entry.value
|
||||
}
|
||||
</ha-list-item-value>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
private _renderAttributes(attributes: { name: string; label: string }[]) {
|
||||
@@ -460,63 +358,11 @@ class HaMoreInfoDetails extends LitElement {
|
||||
}
|
||||
|
||||
ha-grouped-list + ha-grouped-list {
|
||||
margin-top: var(--ha-space-6);
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-list-item-button {
|
||||
--ha-row-item-padding-block: var(--ha-space-2);
|
||||
--ha-row-item-min-height: 40px;
|
||||
--ha-row-item-gap: var(--ha-space-3);
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
ha-list-item-button::part(end) {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-list-item-button img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.link-row .label {
|
||||
flex: 1;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.link-row .value {
|
||||
max-width: 60%;
|
||||
min-width: 0;
|
||||
text-align: end;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.labels {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.labels ha-label {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
ha-icon-next {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-svg-icon.copy-success {
|
||||
color: var(--success-color);
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -28,7 +28,6 @@ import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { mainWindow } from "../../common/dom/get_main_window";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
@@ -48,10 +47,7 @@ import {
|
||||
replaceCurrentUrl,
|
||||
updateHistoryState,
|
||||
} from "../../common/navigate";
|
||||
import {
|
||||
createMoreInfoUrl,
|
||||
decodeMoreInfoUrl,
|
||||
} from "../../common/url/more-info-query-params";
|
||||
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { withViewTransition } from "../../common/util/view-transition";
|
||||
@@ -235,13 +231,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
// Restore the pre-dialog URL only while the URL still carries this
|
||||
// dialog's deep-link params: navigate() waits for the close only up to
|
||||
// DIALOG_WAIT_TIMEOUT and may have committed a new URL already.
|
||||
if (
|
||||
this._returnUrl &&
|
||||
decodeMoreInfoUrl(mainWindow.location.search).entityId === this._entityId
|
||||
) {
|
||||
if (this._returnUrl) {
|
||||
replaceCurrentUrl(this._returnUrl);
|
||||
}
|
||||
this._entityId = undefined;
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface RouteOptions {
|
||||
// Function to load the page.
|
||||
load?: () => Promise<unknown>;
|
||||
cache?: boolean;
|
||||
// Recreate the page when the remaining path (the item id) changes.
|
||||
itemId?: boolean;
|
||||
waitForReady?: boolean;
|
||||
}
|
||||
|
||||
@@ -140,23 +138,10 @@ export class HassRouterPage extends ReactiveElement {
|
||||
}
|
||||
|
||||
if (this._currentPage === newPage) {
|
||||
const oldRoute = changedProps.get("route");
|
||||
const oldTail = oldRoute ? computeRouteTail(oldRoute).path : undefined;
|
||||
const newTail = route ? this._computeTail(route).path : undefined;
|
||||
if (
|
||||
typeof routeOptions === "object" &&
|
||||
routeOptions.itemId &&
|
||||
oldTail !== newTail
|
||||
) {
|
||||
// Fall through to the normal create path so `load` / loading screen
|
||||
// still run. itemId pages are not cached, so this is a new element.
|
||||
this._currentPage = "";
|
||||
} else {
|
||||
if (this.lastChild) {
|
||||
this.updatePageEl(this.lastChild, changedProps);
|
||||
}
|
||||
return;
|
||||
if (this.lastChild) {
|
||||
this.updatePageEl(this.lastChild, changedProps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!routeOptions) {
|
||||
@@ -380,10 +365,7 @@ export class HassRouterPage extends ReactiveElement {
|
||||
this.updatePageEl(panelEl);
|
||||
this.appendChild(panelEl);
|
||||
|
||||
if (
|
||||
(routerOptions.cacheAll || routeOptions.cache) &&
|
||||
!routeOptions.itemId
|
||||
) {
|
||||
if (routerOptions.cacheAll || routeOptions.cache) {
|
||||
this._cache[page] = panelEl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,6 @@ import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
ACTION_ROW_CONFIG_KEYS,
|
||||
pickRowConfig,
|
||||
} from "../../../../data/automation";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
|
||||
@@ -111,8 +107,9 @@ export default class HaAutomationActionEditor extends LitElement {
|
||||
private _onUiChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = {
|
||||
...(this.action.alias ? { alias: this.action.alias } : {}),
|
||||
...(this.action.note ? { note: this.action.note } : {}),
|
||||
...ev.detail.value,
|
||||
...pickRowConfig(this.action, ACTION_ROW_CONFIG_KEYS),
|
||||
};
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
@@ -62,11 +62,9 @@ import type {
|
||||
AutomationClipboard,
|
||||
Condition,
|
||||
} from "../../../../data/automation";
|
||||
import type { ConditionDescriptions } from "../../../../data/condition";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
|
||||
import { validateConfig } from "../../../../data/config";
|
||||
import {
|
||||
conditionDescriptionsContext,
|
||||
fullEntitiesContext,
|
||||
manifestsContext,
|
||||
} from "../../../../data/context";
|
||||
@@ -92,8 +90,6 @@ import { isMac } from "../../../../util/is_mac";
|
||||
import { showEditorToast } from "../editor-toast";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { overflowStyles, rowStyles } from "../styles";
|
||||
import { getDeviceTarget } from "../target/get_device_target";
|
||||
import { getEntityTarget } from "../target/get_entity_target";
|
||||
import "../target/ha-automation-row-targets";
|
||||
import "./ha-automation-action-editor";
|
||||
import type HaAutomationActionEditor from "./ha-automation-action-editor";
|
||||
@@ -209,10 +205,6 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
private _manifests?: DomainManifestLookup;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions?: ConditionDescriptions;
|
||||
|
||||
@state() private _running = false;
|
||||
|
||||
@state() private _runResult?: {
|
||||
@@ -300,19 +292,13 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
? this._extractTargets(this.action as ServiceAction)
|
||||
: type === "device_id" && (this.action as DeviceAction).device_id
|
||||
? { device_id: (this.action as DeviceAction).device_id }
|
||||
: type === "condition"
|
||||
? this._extractConditionTarget(this.action as Condition)
|
||||
: undefined;
|
||||
: undefined;
|
||||
|
||||
const serviceTargetSpec =
|
||||
type === "condition"
|
||||
? this._conditionDescriptions?.[(this.action as Condition).condition]
|
||||
type === "service" && action
|
||||
? this.hass.services?.[computeDomain(action)]?.[computeObjectId(action)]
|
||||
?.target
|
||||
: type === "service" && action
|
||||
? this.hass.services?.[computeDomain(action)]?.[
|
||||
computeObjectId(action)
|
||||
]?.target
|
||||
: undefined;
|
||||
: undefined;
|
||||
|
||||
const noteTooltipText = truncateWithEllipsis(
|
||||
this.action.note?.trim() || "",
|
||||
@@ -791,28 +777,6 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
return {};
|
||||
}
|
||||
|
||||
private _extractConditionTarget(
|
||||
condition: Condition
|
||||
): HassServiceTarget | undefined {
|
||||
if (typeof condition !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if ("target" in condition && condition.target) {
|
||||
return condition.target;
|
||||
}
|
||||
if (
|
||||
(condition.condition === "state" ||
|
||||
condition.condition === "numeric_state") &&
|
||||
"entity_id" in condition
|
||||
) {
|
||||
return getEntityTarget(condition.entity_id);
|
||||
}
|
||||
if (condition.condition === "device" && "device_id" in condition) {
|
||||
return getDeviceTarget(condition.device_id as string);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _renderTargets = memoizeOne(
|
||||
(
|
||||
target?: HassServiceTarget,
|
||||
|
||||
@@ -14,8 +14,6 @@ import type {
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationEditorMode,
|
||||
fetchReplacementDevices,
|
||||
fetchDeviceActions,
|
||||
deviceAutomationsEqual,
|
||||
fetchDeviceActionCapabilities,
|
||||
localizeExtraFieldsComputeHelperCallback,
|
||||
@@ -42,8 +40,6 @@ export class HaDeviceAction extends LitElement {
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _replacementDeviceIds?: string[];
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@@ -99,27 +95,13 @@ export class HaDeviceAction extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
|
||||
this._replacementDeviceIds = await fetchReplacementDevices(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
this.action,
|
||||
compositeSplits,
|
||||
fetchDeviceActions
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (this._loadingCompositeSplits) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
// Resolve the candidates before exposing the split map, so the picker
|
||||
// never offers one that cannot host the automation.
|
||||
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
await this._resolveReplacements(compositeSplits);
|
||||
this._compositeSplits = compositeSplits;
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
@@ -133,7 +115,6 @@ export class HaDeviceAction extends LitElement {
|
||||
return html`
|
||||
<ha-device-picker
|
||||
.value=${deviceId}
|
||||
.replacementDeviceIds=${this._replacementDeviceIds}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._devicePicked}
|
||||
.hass=${this.hass}
|
||||
@@ -175,19 +156,6 @@ export class HaDeviceAction extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
// The picked device only lives here until the configuration catches up.
|
||||
// Once it points somewhere else, undo and redo included, it is stale.
|
||||
const previous = changedProps.get("action");
|
||||
if (previous && previous.device_id !== this.action.device_id) {
|
||||
this._deviceId = undefined;
|
||||
this._replacementDeviceIds = undefined;
|
||||
if (this._compositeSplits) {
|
||||
this._resolveReplacements(this._compositeSplits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.hass.loadBackendTranslation("device_automation");
|
||||
if (!this._capabilities) {
|
||||
@@ -217,15 +185,6 @@ export class HaDeviceAction extends LitElement {
|
||||
|
||||
private _devicePicked(ev) {
|
||||
ev.stopPropagation();
|
||||
// The automation exists as is on the replacement, so only the reference
|
||||
// changes and the rest of the configuration is left untouched.
|
||||
if (this._replacementDeviceIds?.includes(ev.target.value)) {
|
||||
this._deviceId = undefined;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.action, device_id: ev.target.value },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._deviceId = ev.target.value;
|
||||
if (this._deviceId === undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
|
||||
@@ -7,11 +7,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { Condition } from "../../../../data/automation";
|
||||
import {
|
||||
CONDITION_ROW_CONFIG_KEYS,
|
||||
expandConditionWithShorthand,
|
||||
pickRowConfig,
|
||||
} from "../../../../data/automation";
|
||||
import { expandConditionWithShorthand } from "../../../../data/automation";
|
||||
import type { ConditionDescription } from "../../../../data/condition";
|
||||
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -129,8 +125,9 @@ export default class HaAutomationConditionEditor extends LitElement {
|
||||
private _onUiChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = {
|
||||
...(this.condition.alias ? { alias: this.condition.alias } : {}),
|
||||
...(this.condition.note ? { note: this.condition.note } : {}),
|
||||
...ev.detail.value,
|
||||
...pickRowConfig(this.condition, CONDITION_ROW_CONFIG_KEYS),
|
||||
};
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
@@ -226,7 +226,9 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
}
|
||||
<h3 slot="header">
|
||||
${capitalizeFirstLetter(
|
||||
describeCondition(this.condition, this.hass, this._entityReg)
|
||||
describeCondition(this.condition, this.hass, this._entityReg, {
|
||||
hideEntities: true,
|
||||
})
|
||||
)}
|
||||
${
|
||||
target !== undefined || targetRequired
|
||||
|
||||
@@ -14,8 +14,6 @@ import type {
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationEditorMode,
|
||||
fetchReplacementDevices,
|
||||
fetchDeviceConditions,
|
||||
deviceAutomationsEqual,
|
||||
fetchDeviceConditionCapabilities,
|
||||
localizeExtraFieldsComputeHelperCallback,
|
||||
@@ -42,8 +40,6 @@ export class HaDeviceCondition extends LitElement {
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _replacementDeviceIds?: string[];
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@@ -100,27 +96,13 @@ export class HaDeviceCondition extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
|
||||
this._replacementDeviceIds = await fetchReplacementDevices(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
this.condition,
|
||||
compositeSplits,
|
||||
fetchDeviceConditions
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (this._loadingCompositeSplits) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
// Resolve the candidates before exposing the split map, so the picker
|
||||
// never offers one that cannot host the automation.
|
||||
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
await this._resolveReplacements(compositeSplits);
|
||||
this._compositeSplits = compositeSplits;
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
@@ -134,7 +116,6 @@ export class HaDeviceCondition extends LitElement {
|
||||
return html`
|
||||
<ha-device-picker
|
||||
.value=${deviceId}
|
||||
.replacementDeviceIds=${this._replacementDeviceIds}
|
||||
@value-changed=${this._devicePicked}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
@@ -176,19 +157,6 @@ export class HaDeviceCondition extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
// The picked device only lives here until the configuration catches up.
|
||||
// Once it points somewhere else, undo and redo included, it is stale.
|
||||
const previous = changedProps.get("condition");
|
||||
if (previous && previous.device_id !== this.condition.device_id) {
|
||||
this._deviceId = undefined;
|
||||
this._replacementDeviceIds = undefined;
|
||||
if (this._compositeSplits) {
|
||||
this._resolveReplacements(this._compositeSplits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.hass.loadBackendTranslation("device_automation");
|
||||
if (!this._capabilities) {
|
||||
@@ -219,15 +187,6 @@ export class HaDeviceCondition extends LitElement {
|
||||
|
||||
private _devicePicked(ev) {
|
||||
ev.stopPropagation();
|
||||
// The automation exists as is on the replacement, so only the reference
|
||||
// changes and the rest of the configuration is left untouched.
|
||||
if (this._replacementDeviceIds?.includes(ev.target.value)) {
|
||||
this._deviceId = undefined;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.condition, device_id: ev.target.value },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._deviceId = ev.target.value;
|
||||
if (this._deviceId === undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
|
||||
@@ -278,9 +278,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
const domain = hooks.domain;
|
||||
try {
|
||||
const config = await hooks.fetchFileConfig(this.hass, id);
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.readOnly = false;
|
||||
const report: AutomationMigrationReport = { deprecated: false };
|
||||
this.config = hooks.normalizeConfig(config, report);
|
||||
@@ -297,9 +294,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
);
|
||||
hooks.checkValidation();
|
||||
} catch (err: any) {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (err.status_code !== 404) {
|
||||
const alertText =
|
||||
err.body?.message || err.body || err.error || "Unknown error";
|
||||
|
||||
@@ -47,11 +47,9 @@ class HaConfigAutomation extends HassRouterPage {
|
||||
},
|
||||
edit: {
|
||||
tag: "ha-automation-editor",
|
||||
itemId: true,
|
||||
},
|
||||
show: {
|
||||
tag: "ha-automation-editor",
|
||||
itemId: true,
|
||||
},
|
||||
trace: {
|
||||
tag: "ha-automation-trace",
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
mdiFormatListBulleted,
|
||||
mdiMenuDown,
|
||||
mdiShape,
|
||||
mdiSwapHorizontal,
|
||||
} from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import {
|
||||
@@ -69,9 +68,6 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public interactive = false;
|
||||
|
||||
@property({ reflect: true })
|
||||
public size: "s" | "m" = "m";
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
@@ -322,25 +318,14 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
|
||||
let lastTargetType: string | null = null;
|
||||
|
||||
// The collapsed summary hides the individual targets, so carry over the
|
||||
// warning when any of them no longer exists.
|
||||
const hasMissingTarget = rows.some(
|
||||
([targetType, targetId]) => !this._checkTargetExists(targetType, targetId)
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dropdown
|
||||
@wa-select=${this._handleTargetSelect}
|
||||
@click=${stopPropagation}
|
||||
@keydown=${stopPropagation}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
class=${classMap({ target: true, warning: hasMissingTarget })}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${hasMissingTarget ? mdiAlert : mdiFormatListBulleted}
|
||||
></ha-svg-icon>
|
||||
<button slot="trigger" class="target">
|
||||
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
|
||||
<div class="label">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.target_summary.targets",
|
||||
@@ -479,15 +464,9 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
warning = true;
|
||||
badgeTargetId = undefined;
|
||||
badgeTargetType = undefined;
|
||||
if (
|
||||
targetType === "device" &&
|
||||
this._compositeSplits?.[targetId]?.split_ids.some(
|
||||
(id) => id in this._registries.devices
|
||||
)
|
||||
) {
|
||||
if (targetType === "device" && this._compositeSplits?.[targetId]) {
|
||||
// The device was replaced by one or more split devices; make clear
|
||||
// this reference needs to be updated, distinct from "unknown device".
|
||||
icon = mdiSwapHorizontal;
|
||||
label = this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.target_summary.device_replaced"
|
||||
);
|
||||
@@ -673,23 +652,6 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:host([size="s"]) {
|
||||
min-height: 24px;
|
||||
}
|
||||
:host([size="s"]) .target {
|
||||
height: 24px;
|
||||
}
|
||||
/* A default 24px icon would fill the whole small chip. */
|
||||
:host([size="s"]) .target ha-icon,
|
||||
:host([size="s"]) .target ha-svg-icon,
|
||||
:host([size="s"]) .target ha-domain-icon,
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
button.target {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -704,9 +666,6 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
background-color: var(--ha-color-fill-warning-quiet-resting);
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
}
|
||||
ha-dropdown-item.warning ha-svg-icon {
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
}
|
||||
ha-dropdown-item.warning:hover {
|
||||
background-color: var(--ha-color-fill-warning-quiet-hover);
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
|
||||
@@ -8,11 +8,7 @@ import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { Trigger } from "../../../../data/automation";
|
||||
import {
|
||||
TRIGGER_ROW_CONFIG_KEYS,
|
||||
migrateAutomationTrigger,
|
||||
pickRowConfig,
|
||||
} from "../../../../data/automation";
|
||||
import { migrateAutomationTrigger } from "../../../../data/automation";
|
||||
import type { TriggerDescription } from "../../../../data/trigger";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
@@ -148,8 +144,9 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
if (isTriggerList(this.trigger)) return;
|
||||
ev.stopPropagation();
|
||||
const value = {
|
||||
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
|
||||
...(this.trigger.note ? { note: this.trigger.note } : {}),
|
||||
...ev.detail.value,
|
||||
...pickRowConfig(this.trigger, TRIGGER_ROW_CONFIG_KEYS),
|
||||
};
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
@@ -251,7 +251,9 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
}
|
||||
<h3 slot="header">
|
||||
${capitalizeFirstLetter(
|
||||
describeTrigger(this.trigger, this.hass, this._entityReg)
|
||||
describeTrigger(this.trigger, this.hass, this._entityReg, {
|
||||
hideEntities: true,
|
||||
})
|
||||
)}
|
||||
${
|
||||
target !== undefined || targetRequired
|
||||
|
||||
@@ -16,8 +16,6 @@ import type {
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationEditorMode,
|
||||
fetchReplacementDevices,
|
||||
fetchDeviceTriggers,
|
||||
deviceAutomationsEqual,
|
||||
fetchDeviceTriggerCapabilities,
|
||||
localizeExtraFieldsComputeHelperCallback,
|
||||
@@ -44,8 +42,6 @@ export class HaDeviceTrigger extends LitElement {
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _replacementDeviceIds?: string[];
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@@ -104,27 +100,13 @@ export class HaDeviceTrigger extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
|
||||
this._replacementDeviceIds = await fetchReplacementDevices(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
this.trigger,
|
||||
compositeSplits,
|
||||
fetchDeviceTriggers
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (this._loadingCompositeSplits) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
// Resolve the candidates before exposing the split map, so the picker
|
||||
// never offers one that cannot host the automation.
|
||||
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
await this._resolveReplacements(compositeSplits);
|
||||
this._compositeSplits = compositeSplits;
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
@@ -138,7 +120,6 @@ export class HaDeviceTrigger extends LitElement {
|
||||
return html`
|
||||
<ha-device-picker
|
||||
.value=${deviceId}
|
||||
.replacementDeviceIds=${this._replacementDeviceIds}
|
||||
@value-changed=${this._devicePicked}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
@@ -180,19 +161,6 @@ export class HaDeviceTrigger extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
// The picked device only lives here until the configuration catches up.
|
||||
// Once it points somewhere else, undo and redo included, it is stale.
|
||||
const previous = changedProps.get("trigger");
|
||||
if (previous && previous.device_id !== this.trigger.device_id) {
|
||||
this._deviceId = undefined;
|
||||
this._replacementDeviceIds = undefined;
|
||||
if (this._compositeSplits) {
|
||||
this._resolveReplacements(this._compositeSplits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.hass.loadBackendTranslation("device_automation");
|
||||
if (!this._capabilities) {
|
||||
@@ -240,15 +208,6 @@ export class HaDeviceTrigger extends LitElement {
|
||||
|
||||
private _devicePicked(ev) {
|
||||
ev.stopPropagation();
|
||||
// The automation exists as is on the replacement, so only the reference
|
||||
// changes and the rest of the configuration is left untouched.
|
||||
if (this._replacementDeviceIds?.includes(ev.target.value)) {
|
||||
this._deviceId = undefined;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.trigger, device_id: ev.target.value },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._deviceId = ev.target.value;
|
||||
if (this._deviceId === undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
@@ -266,6 +225,9 @@ export class HaDeviceTrigger extends LitElement {
|
||||
) {
|
||||
trigger = this._origTrigger;
|
||||
}
|
||||
if (this.trigger.id) {
|
||||
trigger.id = this.trigger.id;
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: trigger });
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import type { PlatformTrigger } from "../../../../../data/automation";
|
||||
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
@@ -28,11 +27,15 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
|
||||
!("boolean" in field.selector && field.default);
|
||||
|
||||
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
|
||||
...TRIGGER_ROW_CONFIG_KEYS,
|
||||
"trigger",
|
||||
"target",
|
||||
"alias",
|
||||
"note",
|
||||
"id",
|
||||
"variables",
|
||||
"enabled",
|
||||
"options",
|
||||
];
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-platform")
|
||||
export class HaPlatformTrigger extends LitElement {
|
||||
|
||||
@@ -21,10 +21,14 @@ import {
|
||||
completeCloudOnboarding,
|
||||
fetchCloudSubscriptionInfo,
|
||||
ONBOARDING_ITEMS,
|
||||
removeCloudData,
|
||||
} from "../../../../data/cloud";
|
||||
import type { Webhook } from "../../../../data/webhook";
|
||||
import { fetchWebhooks } from "../../../../data/webhook";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
@@ -37,7 +41,6 @@ import {
|
||||
} from "./cloud-account-status";
|
||||
import { showCloudOnboardingDialog } from "./show-dialog-cloud-onboarding";
|
||||
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
|
||||
import { confirmDeleteCloudData } from "../delete-cloud-data";
|
||||
|
||||
@customElement("cloud-account")
|
||||
export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
@@ -294,7 +297,33 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _deleteCloudData() {
|
||||
await confirmDeleteCloudData(this, this.hass, { signOutFirst: true });
|
||||
const confirm = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_text"
|
||||
),
|
||||
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await cloudLogout(this.hass);
|
||||
await removeCloudData(this.hass);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_failed"
|
||||
),
|
||||
text: err?.message,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
}
|
||||
|
||||
private async _downloadSupportPackage() {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { showSupportPackageDialog } from "./account/show-dialog-cloud-support-package";
|
||||
import { confirmDeleteCloudData } from "./delete-cloud-data";
|
||||
|
||||
// Recovery actions for the signed-out cloud pages, which all need to reach them
|
||||
// — including while a registration is waiting on its email confirmation.
|
||||
@customElement("cloud-signed-out-menu")
|
||||
export class CloudSignedOutMenu extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-dropdown @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-dropdown-item value="reset">
|
||||
${this.hass.localize("ui.panel.config.cloud.account.reset_cloud_data")}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item value="download">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.download_support_package"
|
||||
)}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleMenuAction(ev: HaDropdownSelectEvent) {
|
||||
switch (ev.detail.item.value) {
|
||||
case "reset":
|
||||
this._deleteCloudData();
|
||||
break;
|
||||
case "download":
|
||||
showSupportPackageDialog(this);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteCloudData() {
|
||||
await confirmDeleteCloudData(this, this.hass);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-signed-out-menu": CloudSignedOutMenu;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const cloudSignedOutStyle = css`
|
||||
.content {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: calc(var(--safe-area-inset-bottom) + var(--ha-space-6));
|
||||
}
|
||||
ha-card {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
text-wrap: balance;
|
||||
}
|
||||
`;
|
||||
@@ -1,48 +0,0 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { cloudLogout, removeCloudData } from "../../../data/cloud";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
// Confirms, then wipes every trace of the cloud account from this instance.
|
||||
// `signOutFirst` is for callers reached while signed in: the session has to go
|
||||
// before the data can.
|
||||
export const confirmDeleteCloudData = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
{ signOutFirst = false }: { signOutFirst?: boolean } = {}
|
||||
): Promise<boolean> => {
|
||||
const confirm = await showConfirmationDialog(element, {
|
||||
title: hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_title"
|
||||
),
|
||||
text: hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_text"
|
||||
),
|
||||
confirmText: hass.localize("ui.panel.config.cloud.account.reset"),
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
if (!confirm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (signOutFirst) {
|
||||
await cloudLogout(hass);
|
||||
}
|
||||
await removeCloudData(hass);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(element, {
|
||||
title: hass.localize("ui.panel.config.cloud.account.reset_data_failed"),
|
||||
text: err?.message,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
fireEvent(element, "ha-refresh-cloud-status");
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -169,8 +169,4 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-forgot-password-card": CloudForgotPasswordCard;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"cloud-done": { flashMessage: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export class CloudForgotPassword extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/cloud/login"
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.forgot_password.title"
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { cancelCloudAutoLogin } from "../../../data/cloud";
|
||||
import type { RouterOptions } from "../../../layouts/hass-router-page";
|
||||
import { HassRouterPage } from "../../../layouts/hass-router-page";
|
||||
import type { ValueChangedEvent, HomeAssistant, Route } from "../../../types";
|
||||
import "./account/cloud-account";
|
||||
import "./start/cloud-start";
|
||||
import "./login/cloud-login-panel";
|
||||
|
||||
const LOGGED_IN_URLS = [
|
||||
"account",
|
||||
@@ -20,12 +18,7 @@ const LOGGED_IN_URLS = [
|
||||
"webhooks",
|
||||
] as const;
|
||||
|
||||
const NOT_LOGGED_IN_URLS = [
|
||||
"start",
|
||||
"login",
|
||||
"register",
|
||||
"forgot-password",
|
||||
] as const;
|
||||
const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"] as const;
|
||||
|
||||
type CloudPage =
|
||||
(typeof LOGGED_IN_URLS)[number] | (typeof NOT_LOGGED_IN_URLS)[number];
|
||||
@@ -43,7 +36,7 @@ class HaConfigCloud extends HassRouterPage {
|
||||
@property({ attribute: false }) public cloudStatus!: CloudStatus;
|
||||
|
||||
protected routerOptions: RouterOptions = {
|
||||
defaultPage: "start",
|
||||
defaultPage: "login",
|
||||
showLoading: true,
|
||||
initialLoad: () => this._cloudStatusLoaded,
|
||||
// Guard the different pages based on if we're logged in.
|
||||
@@ -52,30 +45,14 @@ class HaConfigCloud extends HassRouterPage {
|
||||
if (!LOGGED_IN_URLS.some((url) => url === page)) {
|
||||
return "account";
|
||||
}
|
||||
return undefined;
|
||||
} else if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
|
||||
return "login";
|
||||
}
|
||||
|
||||
if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
|
||||
return "start";
|
||||
}
|
||||
|
||||
if (
|
||||
page !== "register" &&
|
||||
this.cloudStatus.auto_login &&
|
||||
!this._autoLoginCancelled
|
||||
) {
|
||||
return "register";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
routes: {
|
||||
start: {
|
||||
tag: "cloud-start",
|
||||
},
|
||||
login: {
|
||||
tag: "cloud-login-panel",
|
||||
load: () => import("./login/cloud-login-panel"),
|
||||
},
|
||||
register: {
|
||||
tag: "cloud-register",
|
||||
@@ -119,10 +96,6 @@ class HaConfigCloud extends HassRouterPage {
|
||||
|
||||
@state() private _loginEmail = "";
|
||||
|
||||
private _autoLoginCancelled = false;
|
||||
|
||||
private _lastPage = "";
|
||||
|
||||
private _resolveCloudStatusLoaded!: () => void;
|
||||
|
||||
private _cloudStatusLoaded = new Promise<void>((resolve) => {
|
||||
@@ -149,14 +122,6 @@ class HaConfigCloud extends HassRouterPage {
|
||||
navigate(this.route.prefix, { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
// A flash belongs to the page it was routed to. Cleared here rather than in
|
||||
// beforeRender, which runs inside update() where assigning state schedules a
|
||||
// second cycle.
|
||||
if (this._lastPage === "login" && this._currentPage !== "login") {
|
||||
this._flashMessage = "";
|
||||
}
|
||||
this._lastPage = this._currentPage;
|
||||
}
|
||||
|
||||
protected createElement(tag: string) {
|
||||
@@ -167,27 +132,9 @@ class HaConfigCloud extends HassRouterPage {
|
||||
el.addEventListener("flash-message-changed", (ev) => {
|
||||
this._flashMessage = (ev as ValueChangedEvent<string>).detail.value;
|
||||
});
|
||||
el.addEventListener("cloud-cancel-auto-login", () => {
|
||||
this._autoLoginCancelled = true;
|
||||
this._cancelAutoLogin();
|
||||
});
|
||||
el.addEventListener("cloud-auto-login-started", () => {
|
||||
this._autoLoginCancelled = false;
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
private async _cancelAutoLogin() {
|
||||
try {
|
||||
await cancelCloudAutoLogin(this.hass);
|
||||
} catch (_err) {
|
||||
// Both callers are fire-and-forget navigations with nowhere to report
|
||||
// this; the refresh below still reconciles whatever the backend kept.
|
||||
} finally {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
}
|
||||
|
||||
protected updatePageEl(el) {
|
||||
// We are not going to update if the current page if we are not logged in
|
||||
// and the current page requires being logged in. Happens when we log out.
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../common/navigate";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-list";
|
||||
import "../../../../components/ha-list-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { removeCloudData } from "../../../../data/cloud";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
|
||||
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
|
||||
import "../../ha-config-section";
|
||||
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
|
||||
import "./cloud-login";
|
||||
import type { CloudLogin } from "./cloud-login";
|
||||
|
||||
@@ -16,88 +30,212 @@ import type { CloudLogin } from "./cloud-login";
|
||||
export class CloudLoginPanel extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property() public email?: string;
|
||||
|
||||
@property({ attribute: false }) public flashMessage?: string;
|
||||
|
||||
@query("cloud-login") private _cloudLoginElement?: CloudLogin;
|
||||
|
||||
protected firstUpdated(): void {
|
||||
this._focusEmail();
|
||||
}
|
||||
@query("cloud-login") private _cloudLoginElement!: CloudLogin;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/cloud/start"
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
|
||||
back-path="/config"
|
||||
header="Home Assistant Cloud"
|
||||
>
|
||||
<div class="content">
|
||||
${
|
||||
this.flashMessage
|
||||
? html`<ha-alert
|
||||
dismissable
|
||||
@alert-dismissed-clicked=${this._dismissFlash}
|
||||
>
|
||||
${this.flashMessage}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<cloud-login
|
||||
.hass=${this.hass}
|
||||
.email=${this.email}
|
||||
.localize=${this.hass.localize}
|
||||
.lead=${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.sign_in_lead"
|
||||
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-dropdown-item value="reset">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_cloud_data"
|
||||
)}
|
||||
check-connection
|
||||
@cloud-forgot-password=${this._handleForgotPassword}
|
||||
></cloud-login>
|
||||
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item value="download">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.download_support_package"
|
||||
)}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<div class="content">
|
||||
<ha-config-section .isWide=${this.isWide}>
|
||||
<span slot="header">Home Assistant Cloud</span>
|
||||
<div slot="introduction">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.introduction"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.introduction2"
|
||||
)}
|
||||
<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Nabu Casa, Inc</a
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.introduction2a"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.introduction3"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.learn_more_link"
|
||||
)}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${
|
||||
this.flashMessage
|
||||
? html`<ha-alert
|
||||
dismissable
|
||||
@alert-dismissed-clicked=${this._dismissFlash}
|
||||
>
|
||||
${this.flashMessage}
|
||||
</ha-alert>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<cloud-login
|
||||
.hass=${this.hass}
|
||||
.email=${this.email}
|
||||
.localize=${this.hass.localize}
|
||||
@cloud-forgot-password=${this._handleForgotPassword}
|
||||
check-connection
|
||||
></cloud-login>
|
||||
|
||||
<ha-card outlined>
|
||||
<ha-list>
|
||||
<ha-list-item @click=${this._handleRegister} twoline hasMeta>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.start_trial"
|
||||
)}
|
||||
<span slot="secondary">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.trial_info"
|
||||
)}
|
||||
</span>
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
</ha-list>
|
||||
</ha-card>
|
||||
</ha-config-section>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _focusEmail() {
|
||||
const cloudLogin = this._cloudLoginElement;
|
||||
if (!cloudLogin) {
|
||||
return;
|
||||
}
|
||||
await cloudLogin.updateComplete;
|
||||
cloudLogin.emailField?.focus();
|
||||
}
|
||||
|
||||
private _handleForgotPassword() {
|
||||
this._dismissFlash();
|
||||
fireEvent(this, "cloud-email-changed", {
|
||||
value: this._cloudLoginElement?.emailField?.value ?? this.email ?? "",
|
||||
value: this._cloudLoginElement.emailField.value ?? "",
|
||||
});
|
||||
navigate("/config/cloud/forgot-password");
|
||||
}
|
||||
|
||||
private _handleRegister() {
|
||||
this._dismissFlash();
|
||||
|
||||
fireEvent(this, "cloud-email-changed", {
|
||||
value: this._cloudLoginElement.emailField.value ?? "",
|
||||
});
|
||||
navigate("/config/cloud/register");
|
||||
}
|
||||
|
||||
private _dismissFlash() {
|
||||
fireEvent(this, "flash-message-changed", { value: "" });
|
||||
}
|
||||
|
||||
private _handleMenuAction(ev: HaDropdownSelectEvent) {
|
||||
const value = ev.detail.item.value;
|
||||
switch (value) {
|
||||
case "reset":
|
||||
this._deleteCloudData();
|
||||
break;
|
||||
case "download":
|
||||
this._downloadSupportPackage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteCloudData() {
|
||||
const confirm = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_text"
|
||||
),
|
||||
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await removeCloudData(this.hass);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_failed"
|
||||
),
|
||||
text: err?.message,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
}
|
||||
|
||||
private async _downloadSupportPackage() {
|
||||
showSupportPackageDialog(this);
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
cloudSignedOutStyle,
|
||||
css`
|
||||
.content {
|
||||
gap: var(--ha-space-4);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
ha-alert,
|
||||
cloud-login {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-inline: auto;
|
||||
[slot="introduction"] {
|
||||
margin: -1em 0;
|
||||
}
|
||||
[slot="introduction"] a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .card-header {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -9,12 +9,14 @@ import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import { setAssistPipelinePreferred } from "../../../../data/assist_pipeline";
|
||||
import { cloudLogin } from "../../../../data/cloud";
|
||||
import { loginHaCloud } from "../../../../data/onboarding";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
showPromptDialog,
|
||||
} from "../../../lovelace/custom-card-helpers";
|
||||
import { showCloudAlreadyConnectedDialog } from "../dialog-cloud-already-connected/show-dialog-cloud-already-connected";
|
||||
@@ -35,8 +37,6 @@ export class CloudLogin extends LitElement {
|
||||
|
||||
@property({ type: Boolean, attribute: "card-less" }) public cardLess = false;
|
||||
|
||||
@property() public lead?: string;
|
||||
|
||||
@query("#email", true) public emailField!: HaInput;
|
||||
|
||||
@query("#password", true) private _passwordField!: HaInput;
|
||||
@@ -50,13 +50,21 @@ export class CloudLogin extends LitElement {
|
||||
return this._renderLoginForm();
|
||||
}
|
||||
|
||||
return html`<ha-card outlined>${this._renderLoginForm()}</ha-card>`;
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.login.sign_in`
|
||||
)}
|
||||
>
|
||||
${this._renderLoginForm()}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderLoginForm() {
|
||||
return html`
|
||||
<div class="card-content login-form">
|
||||
${this.lead ? html`<p class="lead">${this.lead}</p>` : nothing}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
@@ -211,12 +219,28 @@ export class CloudLogin extends LitElement {
|
||||
|
||||
try {
|
||||
if (this.hass) {
|
||||
await cloudLogin({
|
||||
const result = await cloudLogin({
|
||||
hass: this.hass,
|
||||
email,
|
||||
...(code ? { code } : { password }),
|
||||
check_connection: checkConnection,
|
||||
});
|
||||
if (result.cloud_pipeline) {
|
||||
if (
|
||||
await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.login.cloud_pipeline_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.cloud.login.cloud_pipeline_text"
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.yes"),
|
||||
dismissText: this.hass.localize("ui.common.no"),
|
||||
})
|
||||
) {
|
||||
setAssistPipelinePreferred(this.hass, result.cloud_pipeline);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// for onboarding
|
||||
await loginHaCloud({
|
||||
@@ -293,6 +317,9 @@ export class CloudLogin extends LitElement {
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .card-header {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -302,11 +329,6 @@ export class CloudLogin extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lead {
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,324 +1,199 @@
|
||||
import { mdiEmailCheckOutline } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../common/navigate";
|
||||
import "../../../../components/buttons/ha-progress-button";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import type { CloudAutoLogin, CloudStatus } from "../../../../data/cloud";
|
||||
import {
|
||||
attemptCloudAutoLoginNow,
|
||||
cloudRegisterAutoLogin,
|
||||
resendCloudAutoLoginConfirm,
|
||||
subscribeCloudEvents,
|
||||
} from "../../../../data/cloud";
|
||||
import { cloudRegister, cloudResendVerification } from "../../../../data/cloud";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import "../cloud-signed-out-menu";
|
||||
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
|
||||
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
|
||||
|
||||
const BACK_PATH = "/config/cloud/start";
|
||||
import "../../ha-config-section";
|
||||
|
||||
@customElement("cloud-register")
|
||||
export class CloudRegister extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property() public email?: string;
|
||||
|
||||
@property({ attribute: false }) public cloudStatus?: CloudStatus;
|
||||
|
||||
@state() private _view: "form" | "confirm" = "form";
|
||||
|
||||
@state() private _requestInProgress = false;
|
||||
|
||||
@state() private _resendInProgress = false;
|
||||
|
||||
@state() private _autoLoginError?: string;
|
||||
@state() private _password = "";
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _success?: string;
|
||||
@query("#email", true) private _emailField!: HaInput;
|
||||
|
||||
@state() private _registeredEmail = "";
|
||||
|
||||
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
|
||||
|
||||
@query("#email") private _emailField?: HaInput;
|
||||
|
||||
@query("#password") private _passwordField?: HaInput;
|
||||
|
||||
@query("#confirm-title") private _confirmTitle?: HTMLElement;
|
||||
|
||||
private get _autoLogin(): CloudAutoLogin | null {
|
||||
const status = this.cloudStatus;
|
||||
if (!status || status.logged_in) {
|
||||
return null;
|
||||
}
|
||||
return status.auto_login ?? null;
|
||||
}
|
||||
|
||||
private get _pendingEmail(): string {
|
||||
return this._registeredEmail || this._autoLogin?.email || "";
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this._autoLogin) {
|
||||
this._view = "confirm";
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopWatchingAutoLogin();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (this._view !== "confirm") {
|
||||
return;
|
||||
}
|
||||
|
||||
const failedKey = this._autoLogin?.failed;
|
||||
if (failedKey && !this._autoLoginError) {
|
||||
this._failAutoLogin(failedKey);
|
||||
return;
|
||||
}
|
||||
|
||||
this._watchAutoLogin();
|
||||
}
|
||||
@query("#password", true) private _passwordField!: HaInput;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const confirming = this._view === "confirm";
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path=${BACK_PATH}
|
||||
.backCallback=${confirming ? this._handleConfirmBack : undefined}
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.register.headline")}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
|
||||
>
|
||||
<cloud-signed-out-menu
|
||||
slot="toolbar-icon"
|
||||
.hass=${this.hass}
|
||||
></cloud-signed-out-menu>
|
||||
<div class="content">
|
||||
${confirming ? this._renderConfirm() : this._renderForm()}
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderForm(): TemplateResult {
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="card-header-block">
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.create_account"
|
||||
)}
|
||||
</h2>
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.cloud.register.information")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-content register-form">
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<ha-input
|
||||
autofocus
|
||||
id="email"
|
||||
name="email"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.email_address"
|
||||
)}
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
.value=${this.email ?? ""}
|
||||
.disabled=${this._requestInProgress}
|
||||
@keydown=${this._keyDown}
|
||||
.validationMessage=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.email_error_msg"
|
||||
)}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
id="password"
|
||||
type="password"
|
||||
password-toggle
|
||||
name="password"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.password"
|
||||
)}
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
.disabled=${this._requestInProgress}
|
||||
@keydown=${this._keyDown}
|
||||
.validationMessage=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.password_error_msg"
|
||||
)}
|
||||
></ha-input>
|
||||
<p class="terms">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.terms_notice",
|
||||
{
|
||||
terms: html`<a
|
||||
href="https://www.nabucasa.com/tos/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.link_terms_conditions"
|
||||
)}</a
|
||||
>`,
|
||||
privacy_policy: html`<a
|
||||
href="https://www.nabucasa.com/privacy_policy/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.link_privacy_policy"
|
||||
)}</a
|
||||
>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions split">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
.disabled=${this._requestInProgress}
|
||||
@click=${this._handleSignInInstead}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.sign_in_instead"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-progress-button
|
||||
@click=${this._handleRegister}
|
||||
.progress=${this._requestInProgress}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.start_trial"
|
||||
)}</ha-progress-button
|
||||
>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderConfirm(): TemplateResult {
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="card-content confirm">
|
||||
${
|
||||
this._autoLoginError
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this._autoLoginError}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._success
|
||||
? html`<ha-alert alert-type="success">${this._success}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<div class="confirm-icon">
|
||||
<ha-svg-icon .path=${mdiEmailCheckOutline}></ha-svg-icon>
|
||||
</div>
|
||||
<h2 id="confirm-title" tabindex="-1">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.check_your_email"
|
||||
)}
|
||||
</h2>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.confirm_email_body",
|
||||
{
|
||||
email: html`<span class="email">${this._pendingEmail}</span>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
this._autoLoginError
|
||||
? nothing
|
||||
: html`
|
||||
<div class="waiting">
|
||||
<ha-spinner size="tiny"></ha-spinner>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.waiting_for_confirmation"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="card-actions split confirm-actions">
|
||||
<ha-button appearance="plain" @click=${this._handleCancelPending}>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
${
|
||||
this._autoLoginError
|
||||
? html`<ha-button
|
||||
appearance="accent"
|
||||
@click=${this._handleSignInInstead}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
|
||||
</ha-button>`
|
||||
: html`<ha-button
|
||||
appearance="accent"
|
||||
@click=${this._handleAttemptNow}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.clicked_confirm"
|
||||
)}
|
||||
</ha-button>`
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
${
|
||||
this._autoLoginError
|
||||
? nothing
|
||||
: html`
|
||||
<p class="footnote">
|
||||
<ha-config-section .isWide=${this.isWide}>
|
||||
<span slot="header"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.headline"
|
||||
)}</span
|
||||
>
|
||||
<div slot="introduction">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.nothing_arrived",
|
||||
{
|
||||
resend_link: html`<button
|
||||
class="link"
|
||||
.disabled=${this._resendInProgress}
|
||||
@click=${this._handleResendVerifyEmail}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.resend_link"
|
||||
)}
|
||||
</button>`,
|
||||
}
|
||||
"ui.panel.config.cloud.register.information"
|
||||
)}
|
||||
</p>
|
||||
`
|
||||
}
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information2"
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.feature_remote_control"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.feature_google_home"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.feature_amazon_alexa"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.feature_webhook_apps"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information3"
|
||||
)}
|
||||
<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Nabu Casa, Inc</a
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information3a"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information4"
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.nabucasa.com/tos/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.link_terms_conditions"
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.nabucasa.com/privacy_policy/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.link_privacy_policy"
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.create_account"
|
||||
)}
|
||||
><div class="card-content register-form">
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error"
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: ""
|
||||
}
|
||||
<ha-input
|
||||
autofocus
|
||||
id="email"
|
||||
name="email"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.email_address"
|
||||
)}
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
.value=${this.email ?? ""}
|
||||
@keydown=${this._keyDown}
|
||||
.validationMessage=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.email_error_msg"
|
||||
)}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
id="password"
|
||||
type="password"
|
||||
password-toggle
|
||||
name="password"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.password"
|
||||
)}
|
||||
.value=${this._password}
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
@keydown=${this._keyDown}
|
||||
.validationMessage=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.password_error_msg"
|
||||
)}
|
||||
></ha-input>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="link"
|
||||
.disabled=${this._requestInProgress}
|
||||
@click=${this._handleResendVerifyEmail}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.resend_confirm_email"
|
||||
)}
|
||||
</button>
|
||||
<ha-progress-button
|
||||
@click=${this._handleRegister}
|
||||
.progress=${this._requestInProgress}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.start_trial"
|
||||
)}</ha-progress-button
|
||||
>
|
||||
</div>
|
||||
</ha-card>
|
||||
</ha-config-section>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -328,50 +203,10 @@ export class CloudRegister extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSignInInstead() {
|
||||
const confirming = this._view === "confirm";
|
||||
fireEvent(this, "cloud-email-changed", {
|
||||
// The field wins on the form: it holds whatever was typed after a cancel.
|
||||
value: this._emailField?.value || this._pendingEmail || this.email || "",
|
||||
});
|
||||
this._stopWatchingAutoLogin();
|
||||
if (confirming) {
|
||||
fireEvent(this, "cloud-cancel-auto-login");
|
||||
}
|
||||
// Replaces only from the waiting view, whose registration is being
|
||||
// cancelled: browser-back must not return to it. The form is a page worth
|
||||
// going back to.
|
||||
navigate("/config/cloud/login", { replace: confirming });
|
||||
}
|
||||
|
||||
private _handleConfirmBack = () => {
|
||||
this._stopWatchingAutoLogin();
|
||||
fireEvent(this, "cloud-cancel-auto-login");
|
||||
navigate(BACK_PATH, { replace: true });
|
||||
};
|
||||
|
||||
private _handleCancelPending() {
|
||||
this._resetToForm();
|
||||
fireEvent(this, "cloud-cancel-auto-login");
|
||||
}
|
||||
|
||||
private _resetToForm() {
|
||||
this._stopWatchingAutoLogin();
|
||||
this._view = "form";
|
||||
this._autoLoginError = undefined;
|
||||
this._registeredEmail = "";
|
||||
this._error = undefined;
|
||||
this._success = undefined;
|
||||
}
|
||||
|
||||
private async _handleRegister() {
|
||||
const emailField = this._emailField;
|
||||
const passwordField = this._passwordField;
|
||||
|
||||
if (!emailField || !passwordField) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emailField.reportValidity()) {
|
||||
passwordField.reportValidity();
|
||||
emailField.focus();
|
||||
@@ -387,246 +222,92 @@ export class CloudRegister extends LitElement {
|
||||
const password = passwordField.value || "";
|
||||
|
||||
this._requestInProgress = true;
|
||||
this._error = undefined;
|
||||
|
||||
try {
|
||||
await cloudRegisterAutoLogin(this.hass, email, password);
|
||||
await cloudRegister(this.hass, email, password);
|
||||
this._verificationEmailSent(email, "account_created");
|
||||
} catch (err: any) {
|
||||
this._password = "";
|
||||
this._requestInProgress = false;
|
||||
passwordField.value = "";
|
||||
this._error =
|
||||
err?.body?.message || this.hass.localize("ui.common.unknown_error");
|
||||
return;
|
||||
err && err.body && err.body.message
|
||||
? err.body.message
|
||||
: "Unknown error";
|
||||
}
|
||||
|
||||
this._registeredEmail = email;
|
||||
this._requestInProgress = false;
|
||||
this._view = "confirm";
|
||||
this._autoLoginError = undefined;
|
||||
fireEvent(this, "cloud-email-changed", { value: email });
|
||||
fireEvent(this, "cloud-auto-login-started");
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
this._watchAutoLogin();
|
||||
await this.updateComplete;
|
||||
this._confirmTitle?.focus();
|
||||
}
|
||||
|
||||
private _watchAutoLogin() {
|
||||
if (this._unsubCloudEvents || this._autoLoginError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = subscribeCloudEvents(this.hass, (event) => {
|
||||
if (event.type === "login") {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
} else if (event.type === "auto_login_failed") {
|
||||
this._failAutoLogin(event.translation_key);
|
||||
} else if (event.type === "auto_login_cancelled") {
|
||||
this._resetToForm();
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
});
|
||||
this._unsubCloudEvents = subscription;
|
||||
|
||||
subscription.catch(() => {
|
||||
if (this._unsubCloudEvents === subscription) {
|
||||
this._unsubCloudEvents = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _localizeBackendMessage(
|
||||
key: string,
|
||||
domain = "cloud"
|
||||
): Promise<string | undefined> {
|
||||
const localize = await this.hass.loadBackendTranslation(
|
||||
"exceptions",
|
||||
domain
|
||||
);
|
||||
return (
|
||||
localize(`component.${domain}.exceptions.${key}.message`) || undefined
|
||||
);
|
||||
}
|
||||
|
||||
private async _websocketErrorMessage(err: any): Promise<string> {
|
||||
if (err?.translation_key) {
|
||||
const message = await this._localizeBackendMessage(
|
||||
err.translation_key,
|
||||
err.translation_domain
|
||||
);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return err?.message || this.hass.localize("ui.common.unknown_error");
|
||||
}
|
||||
|
||||
private async _failAutoLogin(translationKey?: string | null) {
|
||||
this._stopWatchingAutoLogin();
|
||||
const reason = translationKey
|
||||
? await this._localizeBackendMessage(translationKey)
|
||||
: undefined;
|
||||
this._error = undefined;
|
||||
this._success = undefined;
|
||||
this._autoLoginError =
|
||||
reason ||
|
||||
this.hass.localize("ui.panel.config.cloud.register.auto_login_failed");
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
|
||||
private _stopWatchingAutoLogin() {
|
||||
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsubCloudEvents = undefined;
|
||||
}
|
||||
|
||||
private async _handleAttemptNow() {
|
||||
this._watchAutoLogin();
|
||||
|
||||
try {
|
||||
await attemptCloudAutoLoginNow(this.hass);
|
||||
} catch (err: any) {
|
||||
showToast(this, { message: await this._websocketErrorMessage(err) });
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(this, {
|
||||
message: {
|
||||
translationKey: "ui.panel.config.cloud.register.checking_confirmation",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async _handleResendVerifyEmail() {
|
||||
if (this._resendInProgress) {
|
||||
const emailField = this._emailField;
|
||||
|
||||
if (!emailField.reportValidity()) {
|
||||
emailField.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
this._error = undefined;
|
||||
this._success = undefined;
|
||||
this._resendInProgress = true;
|
||||
const email = emailField.value || "";
|
||||
|
||||
try {
|
||||
await resendCloudAutoLoginConfirm(this.hass);
|
||||
this._success = this.hass.localize(
|
||||
"ui.panel.config.cloud.register.verification_email_sent"
|
||||
);
|
||||
} catch (err: any) {
|
||||
this._error = await this._websocketErrorMessage(err);
|
||||
} finally {
|
||||
this._resendInProgress = false;
|
||||
}
|
||||
this._requestInProgress = true;
|
||||
|
||||
const doResend = async (username: string) => {
|
||||
try {
|
||||
await cloudResendVerification(this.hass, username);
|
||||
this._verificationEmailSent(username, "verification_email_sent");
|
||||
} catch (err: any) {
|
||||
const errCode = err && err.body && err.body.code;
|
||||
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
|
||||
await doResend(username.toLowerCase());
|
||||
} else {
|
||||
this._requestInProgress = false;
|
||||
this._error =
|
||||
err && err.body && err.body.message
|
||||
? err.body.message
|
||||
: "Unknown error";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await doResend(email);
|
||||
}
|
||||
|
||||
private _verificationEmailSent(
|
||||
email: string,
|
||||
messageKey: "account_created" | "verification_email_sent"
|
||||
) {
|
||||
this._requestInProgress = false;
|
||||
this._password = "";
|
||||
fireEvent(this, "cloud-email-changed", { value: email });
|
||||
fireEvent(this, "cloud-done", {
|
||||
flashMessage: this.hass.localize(
|
||||
`ui.panel.config.cloud.register.${messageKey}`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
cloudSignedOutStyle,
|
||||
css`
|
||||
.content {
|
||||
gap: var(--ha-space-3);
|
||||
[slot="introduction"] {
|
||||
margin: -1em 0;
|
||||
}
|
||||
|
||||
.card-header-block {
|
||||
padding: var(--ha-space-4) var(--ha-space-4) 0;
|
||||
[slot="introduction"] a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.card-header-block p {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terms {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.terms a,
|
||||
.footnote a,
|
||||
.footnote button.link {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.card-actions.split {
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.card-actions.confirm-actions {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--ha-space-3);
|
||||
padding: var(--ha-space-8) var(--ha-space-4) var(--ha-space-5);
|
||||
}
|
||||
.confirm ha-alert {
|
||||
width: 100%;
|
||||
text-align: initial;
|
||||
}
|
||||
.confirm-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background: color-mix(in srgb, var(--info-color) 18%, transparent);
|
||||
color: var(--info-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
--mdc-icon-size: 28px;
|
||||
}
|
||||
/* Focused programmatically to announce the view; no visible ring. */
|
||||
#confirm-title:focus {
|
||||
outline: none;
|
||||
}
|
||||
.confirm p {
|
||||
margin: 0;
|
||||
max-width: 420px;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.confirm .email {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Not column-reverse: that paints the buttons against DOM order, so
|
||||
keyboard and screen-reader users reach them back to front. */
|
||||
@media (max-width: 500px) {
|
||||
.card-actions.split {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -638,7 +319,6 @@ declare global {
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"cloud-cancel-auto-login": undefined;
|
||||
"cloud-auto-login-started": undefined;
|
||||
"cloud-done": { flashMessage: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import {
|
||||
mdiBackupRestore,
|
||||
mdiCellphone,
|
||||
mdiEarth,
|
||||
mdiHandHeart,
|
||||
mdiMicrophone,
|
||||
mdiMicrophoneMessage,
|
||||
} from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { navigate } from "../../../../common/navigate";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../cloud-signed-out-menu";
|
||||
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
|
||||
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: mdiHandHeart, color: "--red-color", key: "feature_support" },
|
||||
{ icon: mdiEarth, color: "--blue-color", key: "feature_remote" },
|
||||
{ icon: mdiBackupRestore, color: "--green-color", key: "feature_backup" },
|
||||
{
|
||||
icon: mdiMicrophoneMessage,
|
||||
color: "--cyan-color",
|
||||
key: "feature_voice_control",
|
||||
},
|
||||
{
|
||||
icon: mdiMicrophone,
|
||||
color: "--purple-color",
|
||||
key: "feature_voice_quality",
|
||||
},
|
||||
{ icon: mdiCellphone, color: "--primary-color", key: "feature_companion" },
|
||||
] as const;
|
||||
|
||||
@customElement("cloud-start")
|
||||
export class CloudStart extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.landing_title"
|
||||
)}
|
||||
>
|
||||
<cloud-signed-out-menu
|
||||
slot="toolbar-icon"
|
||||
.hass=${this.hass}
|
||||
></cloud-signed-out-menu>
|
||||
<div class="content">
|
||||
<div class="landing">
|
||||
<div class="pitch">
|
||||
<h2>
|
||||
${this.hass.localize("ui.panel.config.cloud.login.hero_title")}
|
||||
</h2>
|
||||
<p class="lead">
|
||||
${this.hass.localize("ui.panel.config.cloud.login.hero_lead")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ha-card outlined class="features">
|
||||
<div class="card-content feature-list">
|
||||
${FEATURES.map(
|
||||
(feature) => html`
|
||||
<div class="feature">
|
||||
<div
|
||||
class="icon-tile"
|
||||
style=${styleMap({
|
||||
"--feature-tint": `var(${feature.color})`,
|
||||
})}
|
||||
>
|
||||
<ha-svg-icon .path=${feature.icon}></ha-svg-icon>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<div class="feature-title">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.cloud.login.${feature.key}_title`
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.cloud.login.${feature.key}_body`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<ha-button
|
||||
size="l"
|
||||
appearance="accent"
|
||||
@click=${this._handleRegister}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.headline"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._handleSignIn}>
|
||||
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
|
||||
</ha-button>
|
||||
</div>
|
||||
<p class="trial-note">
|
||||
${this.hass.localize("ui.panel.config.cloud.login.trial_note")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footnote">
|
||||
${this.hass.localize("ui.panel.config.cloud.login.partner_note", {
|
||||
nabu_casa_link: html`<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Nabu Casa, Inc</a
|
||||
>`,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleSignIn() {
|
||||
navigate("/config/cloud/login");
|
||||
}
|
||||
|
||||
private _handleRegister() {
|
||||
navigate("/config/cloud/register");
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
cloudSignedOutStyle,
|
||||
css`
|
||||
.content {
|
||||
min-height: 100%;
|
||||
gap: var(--ha-space-4);
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.landing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.pitch .lead {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
line-height: var(--ha-line-height-normal);
|
||||
color: var(--secondary-text-color);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
padding: var(--ha-space-5) var(--ha-space-4);
|
||||
}
|
||||
.feature {
|
||||
display: flex;
|
||||
gap: var(--ha-space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.feature-title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.feature-text p {
|
||||
margin: var(--ha-space-1) 0 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.icon-tile {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--feature-tint) 15%, transparent);
|
||||
color: var(--feature-tint);
|
||||
}
|
||||
|
||||
/* Keeps the primary action reachable while the feature list scrolls. */
|
||||
.actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-3) 0
|
||||
calc(var(--safe-area-inset-bottom) + var(--ha-space-3));
|
||||
background: var(--primary-background-color);
|
||||
box-shadow: 0 -1px 0 var(--divider-color);
|
||||
}
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.action-buttons ha-button {
|
||||
flex: 1;
|
||||
/* Matches the primary action's size="l" height without also giving
|
||||
the secondary its larger type. */
|
||||
--ha-button-height: 48px;
|
||||
}
|
||||
.trial-note {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
/* Keep the footer at the bottom on short pages. */
|
||||
margin: auto auto 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.footnote a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@container (min-width: 700px) {
|
||||
.landing {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
grid-template-rows: auto auto 1fr;
|
||||
column-gap: var(--ha-space-7);
|
||||
row-gap: var(--ha-space-6);
|
||||
align-items: start;
|
||||
max-width: 960px;
|
||||
}
|
||||
.pitch {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.actions {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
position: static;
|
||||
padding: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.features {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
max-width: 340px;
|
||||
}
|
||||
.action-buttons ha-button {
|
||||
flex: initial;
|
||||
width: 100%;
|
||||
}
|
||||
.trial-note {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-start": CloudStart;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import {
|
||||
mdiAccessPoint,
|
||||
mdiBluetooth,
|
||||
mdiCheck,
|
||||
mdiMusic,
|
||||
mdiSwapHorizontal,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomEvent,
|
||||
} from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import {
|
||||
ESPHOME_CAPABILITY_ACCENTS,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
type ESPHomeCapabilityId,
|
||||
type ESPHomeSetupStatus,
|
||||
} from "../../../../data/esphome_setup";
|
||||
|
||||
const CAPABILITY_ICONS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: mdiBluetooth,
|
||||
audio: mdiMusic,
|
||||
connectivity: mdiAccessPoint,
|
||||
serial: mdiSwapHorizontal,
|
||||
};
|
||||
|
||||
const CAPABILITY_TITLE_KEYS: Record<ESPHomeCapabilityId, LocalizeKeys> = {
|
||||
bluetooth: "ui.panel.config.devices.esphome.setup_capability_bluetooth_title",
|
||||
audio: "ui.panel.config.devices.esphome.setup_capability_audio_title",
|
||||
connectivity:
|
||||
"ui.panel.config.devices.esphome.setup_capability_connectivity_title",
|
||||
serial: "ui.panel.config.devices.esphome.setup_capability_serial_title",
|
||||
};
|
||||
|
||||
@customElement("ha-esphome-setup-banner")
|
||||
export class HaESPHomeSetupBanner extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public deviceName!: string;
|
||||
|
||||
@property({ attribute: false }) public status: ESPHomeSetupStatus = {};
|
||||
|
||||
@property({ type: Boolean }) public started = false;
|
||||
|
||||
protected render() {
|
||||
if (!this._i18n) {
|
||||
return nothing;
|
||||
}
|
||||
const localize = this._i18n.localize;
|
||||
const ids = getESPHomeSetupCapabilityIds(this.status);
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="content">
|
||||
<div class="text">
|
||||
<h2>
|
||||
${
|
||||
this.started
|
||||
? localize(
|
||||
"ui.panel.config.devices.esphome.setup_continue_title",
|
||||
{ name: this.deviceName }
|
||||
)
|
||||
: localize("ui.panel.config.devices.esphome.setup_title")
|
||||
}
|
||||
</h2>
|
||||
<p>
|
||||
${localize("ui.panel.config.devices.esphome.setup_intro", {
|
||||
count: ids.length,
|
||||
})}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<ha-button @click=${this._setup}>
|
||||
${localize("ui.panel.config.devices.esphome.setup_action")}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._later}>
|
||||
${localize("ui.panel.config.devices.esphome.setup_later")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
ids.length
|
||||
? html`
|
||||
<div class="pips">
|
||||
${ids.map((id) => this._renderPip(id))}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPip(id: ESPHomeCapabilityId) {
|
||||
const localize = this._i18n!.localize;
|
||||
const completed = this.status[id] === "completed";
|
||||
return html`
|
||||
<div class="pip">
|
||||
<span
|
||||
class="pip-chip"
|
||||
style="--capability-accent: ${ESPHOME_CAPABILITY_ACCENTS[id]}"
|
||||
>
|
||||
<ha-svg-icon .path=${CAPABILITY_ICONS[id]}></ha-svg-icon>
|
||||
${
|
||||
completed
|
||||
? html`
|
||||
<span
|
||||
class="pip-check"
|
||||
role="img"
|
||||
aria-label=${localize(
|
||||
"ui.panel.config.devices.esphome.setup_status_completed"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
|
||||
</span>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</span>
|
||||
<span class="pip-label"> ${localize(CAPABILITY_TITLE_KEYS[id])} </span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _setup() {
|
||||
fireEvent(this, "esphome-setup");
|
||||
}
|
||||
|
||||
private _later() {
|
||||
fireEvent(this, "esphome-setup-later");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
display: block;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
padding: var(--ha-space-5);
|
||||
}
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--ha-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 48ch;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
margin-block-start: var(--ha-space-2);
|
||||
}
|
||||
.pips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--ha-space-3) var(--ha-space-6);
|
||||
align-content: center;
|
||||
}
|
||||
.pip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.pip-chip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background: color-mix(in srgb, var(--capability-accent) 12%, transparent);
|
||||
color: var(--capability-accent);
|
||||
}
|
||||
.pip-chip ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.pip-check {
|
||||
position: absolute;
|
||||
inset-inline-end: -2px;
|
||||
bottom: -2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background: var(--success-color);
|
||||
color: var(--ha-color-on-success-loud);
|
||||
border: 2px solid var(--card-background-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.pip-check ha-svg-icon {
|
||||
--mdc-icon-size: 10px;
|
||||
}
|
||||
.pip-label {
|
||||
min-width: 0;
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-8);
|
||||
}
|
||||
.text {
|
||||
flex: 1.05 1 16rem;
|
||||
}
|
||||
.pips {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-esphome-setup-banner": HaESPHomeSetupBanner;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"esphome-setup": undefined;
|
||||
"esphome-setup-later": undefined;
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"esphome-setup": HASSDomEvent<HASSDomEvents["esphome-setup"]>;
|
||||
"esphome-setup-later": HASSDomEvent<HASSDomEvents["esphome-setup-later"]>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
|
||||
@customElement("ha-esphome-setup-reminder")
|
||||
export class HaESPHomeSetupReminder extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ type: Number }) public remaining = 0;
|
||||
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
protected render() {
|
||||
if (!this._i18n) {
|
||||
return nothing;
|
||||
}
|
||||
const localize = this._i18n.localize;
|
||||
const title =
|
||||
this.remaining > 0
|
||||
? localize("ui.panel.config.devices.esphome.setup_reminder_remaining", {
|
||||
count: this.remaining,
|
||||
})
|
||||
: localize("ui.panel.config.devices.esphome.setup_reminder_done");
|
||||
const lead =
|
||||
this.remaining > 0
|
||||
? localize("ui.panel.config.devices.esphome.setup_reminder_intro", {
|
||||
count: this.count,
|
||||
})
|
||||
: localize(
|
||||
"ui.panel.config.devices.esphome.setup_reminder_done_intro",
|
||||
{ count: this.count }
|
||||
);
|
||||
return html`
|
||||
<ha-card outlined .header=${title}>
|
||||
<div class="card-content">
|
||||
<p>${lead}</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._setup}>
|
||||
${localize("ui.panel.config.devices.esphome.setup_action")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _setup() {
|
||||
fireEvent(this, "esphome-setup");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.card-content {
|
||||
padding-block-end: var(--ha-space-6);
|
||||
}
|
||||
/* Same as Device info slot="actions" on ha-config-device-page
|
||||
(and ha-device-entities-card). */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--ha-space-1) var(--ha-space-4) var(--ha-space-1)
|
||||
var(--ha-space-1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-esphome-setup-reminder": HaESPHomeSetupReminder;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
mdiTextureBox,
|
||||
mdiTools,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -83,12 +83,35 @@ import {
|
||||
findBatteryEntity,
|
||||
updateEntityRegistryEntry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
fetchESPHomeDeviceCapabilities,
|
||||
type ESPHomeDeviceCapabilities,
|
||||
} from "../../../data/esphome";
|
||||
import {
|
||||
countRemainingESPHomeCapabilities,
|
||||
deriveESPHomeSetupStatus,
|
||||
deviceHasMediaPlayerEntity,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
hasESPHomeSetupCapabilities,
|
||||
hasStartedNonBluetoothESPHomeSetup,
|
||||
hasZWaveJSEntryForDevice,
|
||||
isESPHomeSerialConfigured,
|
||||
isESPHomeSetupDeferred,
|
||||
withDeferredESPHomeDevice,
|
||||
} from "../../../data/esphome_setup";
|
||||
import {
|
||||
saveFrontendUserData,
|
||||
subscribeFrontendUserData,
|
||||
type ESPHomeFrontendUserData,
|
||||
} from "../../../data/frontend";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import { listSerialPortsWithUsage } from "../../../data/usb";
|
||||
import { regenerateEntityIds } from "../../../data/regenerate_entity_ids";
|
||||
import type { RelatedResult } from "../../../data/search";
|
||||
import { findRelated } from "../../../data/search";
|
||||
import { filterAddToSceneEntityIds } from "../../../dialogs/add-to/add-to";
|
||||
import { showESPHomeDeviceSetupDialog } from "../../../dialogs/esphome-device-setup/show-dialog-esphome-device-setup";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -110,6 +133,8 @@ import "../../logbook/ha-logbook";
|
||||
import "./device-detail/ha-device-child-devices-card";
|
||||
import "./device-detail/ha-device-entities-card";
|
||||
import "./device-detail/ha-device-info-card";
|
||||
import "./device-detail/ha-esphome-setup-banner";
|
||||
import "./device-detail/ha-esphome-setup-reminder";
|
||||
import "./device-detail/ha-device-linked-devices-card";
|
||||
import "./device-detail/ha-device-via-devices-card";
|
||||
import { showDeviceAddToDialog } from "./device-detail/show-dialog-device-add-to";
|
||||
@@ -206,8 +231,20 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
@state() private _deviceAlerts: DeviceAlert[] = [];
|
||||
|
||||
@state() private _esphomeCapabilities?: ESPHomeDeviceCapabilities;
|
||||
|
||||
@state() private _esphomeSerialConfigured = false;
|
||||
|
||||
@state() private _esphomeUserData: ESPHomeFrontendUserData | null = null;
|
||||
|
||||
@state() private _esphomeUserDataReady = false;
|
||||
|
||||
private _deviceAlertsActionsTimeout?: number;
|
||||
|
||||
private _unsubEsphomeUserData?: UnsubscribeFunc;
|
||||
|
||||
private _esphomeUserDataSubGeneration = 0;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
@@ -367,6 +404,8 @@ export class HaConfigDevicePage extends LitElement {
|
||||
this._deviceAlerts = [];
|
||||
this._deleteButtons = [];
|
||||
this._diagnosticDownloadLinks = [];
|
||||
this._esphomeCapabilities = undefined;
|
||||
this._esphomeSerialConfigured = false;
|
||||
}
|
||||
|
||||
if (changedProps.has("deviceId") || changedProps.has("entries")) {
|
||||
@@ -377,6 +416,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
loadDeviceRegistryDetailDialog();
|
||||
this._subscribeESPHomeUserData();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
@@ -390,9 +430,19 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
this._subscribeESPHomeUserData();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearTimeout(this._deviceAlertsActionsTimeout);
|
||||
this._esphomeUserDataSubGeneration += 1;
|
||||
this._unsubEsphomeUserData?.();
|
||||
this._unsubEsphomeUserData = undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -446,6 +496,44 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: undefined;
|
||||
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
|
||||
|
||||
const mediaPlayerSupported = deviceHasMediaPlayerEntity(
|
||||
this.deviceId,
|
||||
entities
|
||||
);
|
||||
const showESPHomeSetup =
|
||||
this._esphomeUserDataReady &&
|
||||
hasESPHomeSetupCapabilities(this._esphomeCapabilities, {
|
||||
mediaPlayerSupported,
|
||||
});
|
||||
const esphomeDeferred = isESPHomeSetupDeferred(
|
||||
this._esphomeUserData,
|
||||
this.deviceId
|
||||
);
|
||||
const esphomeStatus = this._esphomeCapabilities
|
||||
? deriveESPHomeSetupStatus(this._esphomeCapabilities, {
|
||||
mediaPlayerSupported,
|
||||
musicAssistantLoaded: isComponentLoaded(
|
||||
this.hass.config,
|
||||
"music_assistant"
|
||||
),
|
||||
zwaveJsEntryExists: hasZWaveJSEntryForDevice(
|
||||
this.deviceId,
|
||||
this.hass.devices,
|
||||
this.entries
|
||||
),
|
||||
serialConfigured: this._esphomeSerialConfigured,
|
||||
})
|
||||
: undefined;
|
||||
const esphomeRemaining = esphomeStatus
|
||||
? countRemainingESPHomeCapabilities(esphomeStatus)
|
||||
: 0;
|
||||
const esphomeCapabilityCount = esphomeStatus
|
||||
? getESPHomeSetupCapabilityIds(esphomeStatus).length
|
||||
: 0;
|
||||
const esphomeStarted = esphomeStatus
|
||||
? hasStartedNonBluetoothESPHomeSetup(esphomeStatus)
|
||||
: false;
|
||||
|
||||
const deviceInfo: TemplateResult[] = integrations.length
|
||||
? [
|
||||
html`<ha-list-nav slot="actions">
|
||||
@@ -901,6 +989,15 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: ""
|
||||
}
|
||||
</ha-device-info-card>
|
||||
${
|
||||
showESPHomeSetup && esphomeDeferred
|
||||
? html`<ha-esphome-setup-reminder
|
||||
.remaining=${esphomeRemaining}
|
||||
.count=${esphomeCapabilityCount}
|
||||
@esphome-setup=${this._showESPHomeSetup}
|
||||
></ha-esphome-setup-reminder>`
|
||||
: nothing
|
||||
}
|
||||
<ha-device-child-devices-card
|
||||
.hass=${this.hass}
|
||||
.deviceId=${this.deviceId}
|
||||
@@ -1115,6 +1212,20 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
showESPHomeSetup && !esphomeDeferred
|
||||
? html`
|
||||
<ha-esphome-setup-banner
|
||||
class="fullwidth"
|
||||
.deviceName=${deviceName}
|
||||
.status=${esphomeStatus}
|
||||
.started=${esphomeStarted}
|
||||
@esphome-setup=${this._showESPHomeSetup}
|
||||
@esphome-setup-later=${this._deferESPHomeSetup}
|
||||
></ha-esphome-setup-banner>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${columnContents.map(
|
||||
(contents) => html`<div class="column">${contents}</div>`
|
||||
)}
|
||||
@@ -1129,6 +1240,124 @@ export class HaConfigDevicePage extends LitElement {
|
||||
clearTimeout(this._deviceAlertsActionsTimeout);
|
||||
this._getDeviceActions();
|
||||
this._getDeviceAlerts();
|
||||
this._fetchESPHomeCapabilities();
|
||||
}
|
||||
}
|
||||
|
||||
private async _subscribeESPHomeUserData() {
|
||||
const generation = this._esphomeUserDataSubGeneration;
|
||||
try {
|
||||
const unsub = await subscribeFrontendUserData(
|
||||
this.hass.connection,
|
||||
"esphome",
|
||||
({ value }) => {
|
||||
if (generation !== this._esphomeUserDataSubGeneration) {
|
||||
return;
|
||||
}
|
||||
this._esphomeUserData = value;
|
||||
this._esphomeUserDataReady = true;
|
||||
}
|
||||
);
|
||||
if (generation !== this._esphomeUserDataSubGeneration) {
|
||||
unsub();
|
||||
return;
|
||||
}
|
||||
this._unsubEsphomeUserData = unsub;
|
||||
} catch (_err) {
|
||||
if (generation !== this._esphomeUserDataSubGeneration) {
|
||||
return;
|
||||
}
|
||||
this._esphomeUserData = null;
|
||||
this._esphomeUserDataReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchESPHomeCapabilities() {
|
||||
const deviceId = this.deviceId;
|
||||
const device = this.hass.devices[deviceId];
|
||||
if (!device) {
|
||||
this._esphomeCapabilities = undefined;
|
||||
this._esphomeSerialConfigured = false;
|
||||
return;
|
||||
}
|
||||
const domains = this._integrations(
|
||||
device,
|
||||
this.entries,
|
||||
this.manifests
|
||||
).map((entry) => entry.domain);
|
||||
if (!domains.includes("esphome")) {
|
||||
this._esphomeCapabilities = undefined;
|
||||
this._esphomeSerialConfigured = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const capabilities = await fetchESPHomeDeviceCapabilities(
|
||||
this.hass,
|
||||
deviceId
|
||||
);
|
||||
let serialConfigured = false;
|
||||
if (
|
||||
capabilities.serial_proxies.length > 0 &&
|
||||
isComponentLoaded(this.hass.config, "usb")
|
||||
) {
|
||||
try {
|
||||
const ports = await listSerialPortsWithUsage(this.hass);
|
||||
serialConfigured = isESPHomeSerialConfigured(
|
||||
capabilities.serial_proxies,
|
||||
ports
|
||||
);
|
||||
} catch (_err) {
|
||||
serialConfigured = false;
|
||||
}
|
||||
}
|
||||
if (this.deviceId !== deviceId) {
|
||||
return;
|
||||
}
|
||||
this._esphomeCapabilities = capabilities;
|
||||
this._esphomeSerialConfigured = serialConfigured;
|
||||
} catch (_err) {
|
||||
if (this.deviceId !== deviceId) {
|
||||
return;
|
||||
}
|
||||
this._esphomeCapabilities = undefined;
|
||||
this._esphomeSerialConfigured = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _showESPHomeSetup = () => {
|
||||
const device = this.hass.devices[this.deviceId];
|
||||
showESPHomeDeviceSetupDialog(this, {
|
||||
deviceId: this.deviceId,
|
||||
deviceName: device
|
||||
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
|
||||
: undefined,
|
||||
capabilities: this._esphomeCapabilities,
|
||||
mediaPlayerSupported: deviceHasMediaPlayerEntity(
|
||||
this.deviceId,
|
||||
this._entities(this.deviceId, this._entityReg, this.hass.devices)
|
||||
),
|
||||
dialogClosedCallback: () => {
|
||||
this._fetchESPHomeCapabilities();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private async _deferESPHomeSetup() {
|
||||
const previous = this._esphomeUserData;
|
||||
const next = withDeferredESPHomeDevice(previous, this.deviceId);
|
||||
this._esphomeUserData = next;
|
||||
try {
|
||||
await saveFrontendUserData(this.hass.connection, "esphome", next);
|
||||
} catch (err: unknown) {
|
||||
this._esphomeUserData = previous;
|
||||
await showAlertDialog(this, {
|
||||
text:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.devices.esphome.setup_error_defer"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import type { CloudStatus } from "../../data/cloud";
|
||||
import { fetchCloudStatus, subscribeCloudEvents } from "../../data/cloud";
|
||||
import { fetchCloudStatus } from "../../data/cloud";
|
||||
import {
|
||||
entityRegistryByEntityId,
|
||||
entityRegistryById,
|
||||
@@ -257,10 +256,6 @@ class HaPanelConfig extends HassRouterPage {
|
||||
|
||||
private _listeners: (() => void)[] = [];
|
||||
|
||||
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _cloudStatusRequestId = 0;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._listeners.push(
|
||||
@@ -273,10 +268,6 @@ class HaPanelConfig extends HassRouterPage {
|
||||
this._wideSidebar = matches;
|
||||
})
|
||||
);
|
||||
if (this.hass && isComponentLoaded(this.hass.config, "cloud")) {
|
||||
this._subscribeCloudEvents();
|
||||
this._updateCloudStatus();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
@@ -284,42 +275,19 @@ class HaPanelConfig extends HassRouterPage {
|
||||
while (this._listeners.length) {
|
||||
this._listeners.pop()!();
|
||||
}
|
||||
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsubCloudEvents = undefined;
|
||||
entityRegistryByEntityId.clear();
|
||||
entityRegistryById.clear();
|
||||
}
|
||||
|
||||
private _subscribeCloudEvents() {
|
||||
if (
|
||||
this._unsubCloudEvents ||
|
||||
!this.hass ||
|
||||
!isComponentLoaded(this.hass.config, "cloud")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = subscribeCloudEvents(this.hass, () => {
|
||||
this._updateCloudStatus();
|
||||
});
|
||||
this._unsubCloudEvents = subscription;
|
||||
|
||||
subscription.catch(() => {
|
||||
if (this._unsubCloudEvents === subscription) {
|
||||
this._unsubCloudEvents = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.hass.loadBackendTranslation("title");
|
||||
this.hass.loadBackendTranslation("services");
|
||||
if (isComponentLoaded(this.hass.config, "cloud")) {
|
||||
this._updateCloudStatus();
|
||||
this.addEventListener("connection-status", (ev) => {
|
||||
if (ev.detail === "connected") {
|
||||
this._updateCloudStatus();
|
||||
this._subscribeCloudEvents();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -353,13 +321,7 @@ class HaPanelConfig extends HassRouterPage {
|
||||
}
|
||||
|
||||
private async _updateCloudStatus() {
|
||||
const requestId = ++this._cloudStatusRequestId;
|
||||
const status = await fetchCloudStatus(this.hass);
|
||||
|
||||
if (requestId !== this._cloudStatusRequestId) {
|
||||
return;
|
||||
}
|
||||
this._cloudStatus = status;
|
||||
this._cloudStatus = await fetchCloudStatus(this.hass);
|
||||
|
||||
if (
|
||||
// Relayer connecting
|
||||
|
||||
@@ -101,16 +101,12 @@ export const renderConfigEntryError = (
|
||||
): TemplateResult => {
|
||||
if (entry.reason) {
|
||||
if (entry.error_reason_translation_key) {
|
||||
// The error may be translated by another integration, e.g. one raised by
|
||||
// a shared helper and owned by the homeassistant integration.
|
||||
const translationDomain =
|
||||
entry.error_reason_translation_domain || entry.domain;
|
||||
const lokalisePromExc = hass
|
||||
.loadBackendTranslation("exceptions", translationDomain)
|
||||
.loadBackendTranslation("exceptions", entry.domain)
|
||||
.then(
|
||||
(localize) =>
|
||||
localize(
|
||||
`component.${translationDomain}.exceptions.${entry.error_reason_translation_key}.message`,
|
||||
`component.${entry.domain}.exceptions.${entry.error_reason_translation_key}.message`,
|
||||
entry.error_reason_translation_placeholders ?? undefined
|
||||
) || entry.reason
|
||||
);
|
||||
|
||||
@@ -267,7 +267,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
})
|
||||
|
||||
@@ -256,14 +256,10 @@ class MqttSubscribeCard extends LitElement {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
ha-button {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
ha-select {
|
||||
width: 96px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
ha-input {
|
||||
flex: 1;
|
||||
@@ -285,6 +281,10 @@ class MqttSubscribeCard extends LitElement {
|
||||
@media screen and (max-width: 600px) {
|
||||
ha-select {
|
||||
display: block;
|
||||
margin-left: 0px;
|
||||
margin-top: 8px;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
ha-input {
|
||||
flex: auto;
|
||||
|
||||
+51
-23
@@ -1,13 +1,11 @@
|
||||
import { mdiContentCopy } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
|
||||
import "../../../../../components/ha-adaptive-dialog";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/item/ha-list-item-value";
|
||||
import "../../../../../components/list/ha-grouped-list";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-dialog";
|
||||
import "../../../../../components/ha-dialog-footer";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { showToast } from "../../../../../util/toast";
|
||||
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
|
||||
@@ -87,34 +85,64 @@ class DialogSerialPortInfo extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.serial.port_information"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="headerActionItems"
|
||||
.label=${this.hass.localize("ui.common.copy")}
|
||||
.path=${mdiContentCopy}
|
||||
@click=${this._copyToClipboard}
|
||||
></ha-icon-button>
|
||||
<ha-grouped-list>
|
||||
${this._fields().map(
|
||||
([label, value]) =>
|
||||
html`<ha-list-item-value .label=${label}
|
||||
>${value}</ha-list-item-value
|
||||
>`
|
||||
)}
|
||||
</ha-grouped-list>
|
||||
</ha-adaptive-dialog>
|
||||
<table>
|
||||
<tbody>
|
||||
${this._fields().map(
|
||||
([label, value]) => html`
|
||||
<tr>
|
||||
<th>${label}</th>
|
||||
<td>${value}</td>
|
||||
</tr>
|
||||
`
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this._copyToClipboard}
|
||||
>
|
||||
${this.hass.localize("ui.common.copy")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this.closeDialog}>
|
||||
${this.hass.localize("ui.common.close")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
ha-grouped-list {
|
||||
--ha-list-item-value-max-width: 80%;
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
|
||||
td {
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
tr:not(:first-child) th,
|
||||
tr:not(:first-child) td {
|
||||
padding-top: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
+9
-24
@@ -192,16 +192,6 @@ export class SerialConfigDashboard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _renderConsumerIcon(src: string, alt: string): TemplateResult {
|
||||
return html`<img
|
||||
slot="start"
|
||||
.src=${src}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${alt}
|
||||
/>`;
|
||||
}
|
||||
|
||||
private _renderConsumer(consumer: SerialPortConsumer): TemplateResult {
|
||||
const href =
|
||||
consumer.kind === "config_entry"
|
||||
@@ -212,26 +202,21 @@ export class SerialConfigDashboard extends LitElement {
|
||||
<ha-md-list-item type="link" href=${href} class="consumer">
|
||||
${
|
||||
consumer.kind === "config_entry"
|
||||
? this._renderConsumerIcon(
|
||||
brandsUrl(
|
||||
? html`<img
|
||||
slot="start"
|
||||
.src=${brandsUrl(
|
||||
{
|
||||
domain: consumer.domain!,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
),
|
||||
consumer.domain!
|
||||
)
|
||||
: consumer.kind === "app"
|
||||
? this._renderConsumerIcon(
|
||||
`/api/hassio/addons/${consumer.slug}/icon`,
|
||||
consumer.slug!
|
||||
)
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPuzzle}
|
||||
></ha-svg-icon>`
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${consumer.domain!}
|
||||
/>`
|
||||
: html`<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>`
|
||||
}
|
||||
<div slot="headline">${this._consumerName(consumer)}</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
|
||||
@@ -40,7 +40,6 @@ class HaConfigScene extends HassRouterPage {
|
||||
},
|
||||
edit: {
|
||||
tag: "ha-scene-editor",
|
||||
itemId: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -911,15 +911,11 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
}
|
||||
|
||||
private async _subscribeEvents() {
|
||||
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
|
||||
(event) => this._stateChanged(event),
|
||||
"state_changed"
|
||||
);
|
||||
if (!this.isConnected || this._mode !== "live") {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
this._unsubscribeEvents = unsubscribe;
|
||||
this._unsubscribeEvents =
|
||||
await this.hass!.connection.subscribeEvents<HassEvent>(
|
||||
(event) => this._stateChanged(event),
|
||||
"state_changed"
|
||||
);
|
||||
}
|
||||
|
||||
private _showMoreInfo(ev: Event) {
|
||||
@@ -932,9 +928,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
try {
|
||||
config = await getSceneConfig(this.hass, this.sceneId!);
|
||||
} catch (err: any) {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
await showAlertDialog(this, {
|
||||
text:
|
||||
err.status_code === 404
|
||||
@@ -950,10 +943,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.entities) {
|
||||
config.entities = {};
|
||||
}
|
||||
|
||||
@@ -40,11 +40,9 @@ class HaConfigScript extends HassRouterPage {
|
||||
},
|
||||
edit: {
|
||||
tag: "ha-script-editor",
|
||||
itemId: true,
|
||||
},
|
||||
show: {
|
||||
tag: "ha-script-editor",
|
||||
itemId: true,
|
||||
},
|
||||
trace: {
|
||||
tag: "ha-script-trace",
|
||||
|
||||
+15
-38
@@ -1,57 +1,34 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDelete } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "./state-badge";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-settings-row";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
} from "../../data/context";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"delete-favorite-entity": { index: number };
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-favorite-entity-list-item": HaFavoriteEntityListItem;
|
||||
"home-favorite-entity-list-item": HomeFavoriteEntityListItem;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ha-favorite-entity-list-item")
|
||||
export class HaFavoriteEntityListItem extends LitElement {
|
||||
@customElement("home-favorite-entity-list-item")
|
||||
export class HomeFavoriteEntityListItem extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "entity-id" }) public entityId!: string;
|
||||
|
||||
@property({ type: Number }) public index = 0;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["entityId"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
protected render() {
|
||||
const stateObj = this._stateObj;
|
||||
const stateObj = this.hass.states[this.entityId];
|
||||
const { primary, secondary } = stateObj
|
||||
? computeEntityPickerDisplay(
|
||||
{
|
||||
...this._registries,
|
||||
language: this._i18n.language,
|
||||
translationMetadata: this._i18n.translationMetadata,
|
||||
},
|
||||
stateObj
|
||||
)
|
||||
? computeEntityPickerDisplay(this.hass, stateObj)
|
||||
: { primary: this.entityId, secondary: undefined };
|
||||
|
||||
return html`
|
||||
@@ -65,7 +42,7 @@ export class HaFavoriteEntityListItem extends LitElement {
|
||||
}
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this._i18n.localize("ui.common.delete")}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
@click=${this._delete}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>
|
||||
+21
-23
@@ -2,28 +2,24 @@ import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "./ha-entity-picker";
|
||||
import "./ha-entity-picker";
|
||||
import "../ha-sortable";
|
||||
import "../ha-svg-icon";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { ValueChangedEvent } from "../../types";
|
||||
import "./ha-favorite-entity-list-item";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "./home-favorite-entity-list-item";
|
||||
|
||||
@customElement("home-favorites-editor")
|
||||
export class HomeFavoritesEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@customElement("ha-favorites-editor")
|
||||
export class HaFavoritesEditor extends LitElement {
|
||||
@property({ attribute: false }) public favorites: string[] = [];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
${this.label ? html`<p class="field-label">${this.label}</p>` : nothing}
|
||||
@@ -31,21 +27,22 @@ export class HaFavoritesEditor extends LitElement {
|
||||
this.helper ? html`<p class="field-helper">${this.helper}</p>` : nothing
|
||||
}
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
|
||||
<div class="favorites-list">
|
||||
<div class="home-list">
|
||||
${repeat(
|
||||
this.favorites,
|
||||
(entityId) => entityId,
|
||||
(entityId, index) => html`
|
||||
<div class="favorite-row">
|
||||
<div class="home-list-item favorite-row">
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<ha-favorite-entity-list-item
|
||||
<home-favorite-entity-list-item
|
||||
class="favorite-content"
|
||||
.hass=${this.hass}
|
||||
.entityId=${entityId}
|
||||
.index=${index}
|
||||
@delete-favorite-entity=${this._remove}
|
||||
></ha-favorite-entity-list-item>
|
||||
></home-favorite-entity-list-item>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
@@ -53,9 +50,10 @@ export class HaFavoritesEditor extends LitElement {
|
||||
</ha-sortable>
|
||||
<ha-entity-picker
|
||||
add-button
|
||||
.addButtonLabel=${this.addButtonLabel}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
|
||||
)}
|
||||
.excludeEntities=${this.favorites}
|
||||
.entityFilter=${this.entityFilter}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
@@ -104,7 +102,7 @@ export class HaFavoritesEditor extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
.favorites-list {
|
||||
.home-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -133,6 +131,6 @@ export class HaFavoritesEditor extends LitElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-favorites-editor": HaFavoritesEditor;
|
||||
"home-favorites-editor": HomeFavoritesEditor;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "../../../components/entity/ha-favorites-editor";
|
||||
import "../components/home-favorites-editor";
|
||||
import "../components/home-shortcuts-editor";
|
||||
import type { EditHomeDialogParams } from "./show-dialog-edit-home";
|
||||
|
||||
@@ -123,16 +123,14 @@ export class DialogEditHome
|
||||
@value-changed=${this._welcomeChanged}
|
||||
></ha-form>
|
||||
|
||||
<ha-favorites-editor
|
||||
<home-favorites-editor
|
||||
.hass=${this.hass}
|
||||
.favorites=${this._state.favorite_entities}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
|
||||
)}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
|
||||
)}
|
||||
@value-changed=${this._favoriteEntitiesChanged}
|
||||
></ha-favorites-editor>
|
||||
></home-favorites-editor>
|
||||
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
@@ -311,7 +309,7 @@ export class DialogEditHome
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-favorites-editor {
|
||||
home-favorites-editor {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-4);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* When battery is charging from grid, per-source import attribution is
|
||||
* ambiguous if multiple grid sources have data in the same period.
|
||||
* Rewrites single-source periods in place on `fromGridBySource`, and returns
|
||||
* a combined used-grid map for multi-source periods. Returns undefined when
|
||||
* no combined series is needed so the chart does not add an empty legend item.
|
||||
*/
|
||||
export function buildCombinedUsedGrid(
|
||||
fromGridBySource: Record<string, Record<number, number>>,
|
||||
gridToBattery: Record<number, number>,
|
||||
usedGrid: Record<number, number>
|
||||
): Record<number, number> | undefined {
|
||||
const used_grid: Record<number, number> = {};
|
||||
for (const [start, grid_to_battery] of Object.entries(gridToBattery)) {
|
||||
if (!grid_to_battery) {
|
||||
continue;
|
||||
}
|
||||
let noOfSources = 0;
|
||||
let source: string | undefined;
|
||||
for (const [key, stats] of Object.entries(fromGridBySource)) {
|
||||
if (stats[start]) {
|
||||
source = key;
|
||||
noOfSources++;
|
||||
}
|
||||
if (noOfSources > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noOfSources === 1 && source) {
|
||||
fromGridBySource[source][start] = usedGrid[start];
|
||||
} else {
|
||||
Object.values(fromGridBySource).forEach((stats) => {
|
||||
delete stats[start];
|
||||
});
|
||||
used_grid[start] = usedGrid[start];
|
||||
}
|
||||
}
|
||||
return Object.keys(used_grid).length > 0 ? used_grid : undefined;
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
} from "./common/energy-chart-options";
|
||||
import type { HaECOption } from "../../../../resources/echarts/echarts";
|
||||
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
|
||||
import { buildCombinedUsedGrid } from "./energy-usage-graph-used-grid";
|
||||
|
||||
const colorPropertyMap = {
|
||||
to_grid: "--energy-grid-return-color",
|
||||
@@ -586,8 +585,7 @@ export class HuiEnergyUsageGraphCard
|
||||
|
||||
// Only add solar/battery consumption series when such a source is
|
||||
// actually configured, otherwise the legend shows empty solar/battery
|
||||
// entries for grid-only setups. Combined used_grid is a fallback for
|
||||
// multi-source battery charging; skip it when it has no points.
|
||||
// entries for grid-only setups.
|
||||
if (statIdsByCat.solar) {
|
||||
combinedData.used_solar = { used_solar: consumptionData.used_solar };
|
||||
}
|
||||
@@ -598,14 +596,38 @@ export class HuiEnergyUsageGraphCard
|
||||
}
|
||||
|
||||
if (combinedData.from_grid && summedData.to_battery) {
|
||||
const used_grid = buildCombinedUsedGrid(
|
||||
combinedData.from_grid,
|
||||
consumptionData.grid_to_battery,
|
||||
consumptionData.used_grid
|
||||
);
|
||||
if (used_grid) {
|
||||
combinedData.used_grid = { used_grid };
|
||||
const used_grid = {};
|
||||
// If we have to_battery and multiple grid sources in the same period, we
|
||||
// can't determine which source was used. So delete all the individual
|
||||
// sources and replace with a 'combined from grid' value.
|
||||
for (const [start, grid_to_battery] of Object.entries(
|
||||
consumptionData.grid_to_battery
|
||||
)) {
|
||||
if (!grid_to_battery) {
|
||||
continue;
|
||||
}
|
||||
let noOfSources = 0;
|
||||
let source: string;
|
||||
for (const [key, stats] of Object.entries(combinedData.from_grid)) {
|
||||
if (stats[start]) {
|
||||
source = key;
|
||||
noOfSources++;
|
||||
}
|
||||
if (noOfSources > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noOfSources === 1) {
|
||||
combinedData.from_grid[source!][start] =
|
||||
consumptionData.used_grid[start];
|
||||
} else {
|
||||
Object.values(combinedData.from_grid).forEach((stats) => {
|
||||
delete stats[start];
|
||||
});
|
||||
used_grid[start] = consumptionData.used_grid[start];
|
||||
}
|
||||
}
|
||||
combinedData.used_grid = { used_grid };
|
||||
}
|
||||
|
||||
const uniqueKeys = summedData.timestamps;
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import type { LovelacePanelConfig } from "../../../data/lovelace";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../data/lovelace/config/types";
|
||||
import {
|
||||
fetchConfig,
|
||||
isStrategyDashboard,
|
||||
saveConfig,
|
||||
} from "../../../data/lovelace/config/types";
|
||||
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
|
||||
import { fetchConfig, saveConfig } from "../../../data/lovelace/config/types";
|
||||
import { fetchDashboards } from "../../../data/lovelace/dashboard";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -44,15 +37,7 @@ export const addEntitiesToLovelaceView = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// A strategy dashboard has no views of its own until the user takes control.
|
||||
const hasViewList = (
|
||||
config: LovelaceRawConfig | undefined
|
||||
): config is LovelaceConfig => !!config && !isStrategyDashboard(config);
|
||||
|
||||
const hasViews = (config: LovelaceRawConfig | undefined) =>
|
||||
hasViewList(config) && !!config.views?.length;
|
||||
|
||||
let lovelaceConfig: LovelaceRawConfig | undefined;
|
||||
let lovelaceConfig;
|
||||
let urlPath: string | null = null;
|
||||
if (mainLovelaceMode === "storage") {
|
||||
try {
|
||||
@@ -62,36 +47,25 @@ export const addEntitiesToLovelaceView = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasViews(lovelaceConfig)) {
|
||||
// Prefer a dashboard that has views to add the card to, but keep the first
|
||||
// usable one as a fallback so the user still gets the dashboard picker.
|
||||
if (!lovelaceConfig && storageDashs.length) {
|
||||
// find first dashoard not in generated mode
|
||||
for (const storageDash of storageDashs) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const dashConfig = await fetchConfig(
|
||||
lovelaceConfig = await fetchConfig(
|
||||
hass.connection,
|
||||
storageDash.url_path,
|
||||
false
|
||||
);
|
||||
if (!hasViewList(dashConfig)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasViewList(lovelaceConfig)) {
|
||||
lovelaceConfig = dashConfig;
|
||||
urlPath = storageDash.url_path;
|
||||
}
|
||||
if (hasViews(dashConfig)) {
|
||||
lovelaceConfig = dashConfig;
|
||||
urlPath = storageDash.url_path;
|
||||
break;
|
||||
}
|
||||
urlPath = storageDash.url_path;
|
||||
break;
|
||||
} catch (_err: any) {
|
||||
// dashboard is in generated mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasViewList(lovelaceConfig)) {
|
||||
if (!lovelaceConfig) {
|
||||
if (dashboards.length > storageDashs.length) {
|
||||
// all storage dashboards are generated, but we have YAML dashboards just show the YAML config
|
||||
showSuggestCardDialog(element, {
|
||||
@@ -120,7 +94,7 @@ export const addEntitiesToLovelaceView = async (
|
||||
showSuggestCardDialog(element, {
|
||||
cardConfig,
|
||||
sectionConfig,
|
||||
lovelaceConfig,
|
||||
lovelaceConfig: lovelaceConfig!,
|
||||
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
|
||||
try {
|
||||
await saveConfig(hass!, null, newConfig);
|
||||
|
||||
@@ -9,14 +9,13 @@ import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-list";
|
||||
import "../../../../components/ha-radio-list-item";
|
||||
import "../../../../components/ha-select";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-dialog";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import { fetchConfig } from "../../../../data/lovelace/config/types";
|
||||
import { isStrategyView } from "../../../../data/lovelace/config/view";
|
||||
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
|
||||
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
|
||||
import { LOVELACE_PANEL } from "../../../../data/panel";
|
||||
import { getDefaultPanelUrlPath } from "../../../../data/panel";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { SelectViewDialogParams } from "./show-select-view-dialog";
|
||||
@@ -43,15 +42,11 @@ export class HuiDialogSelectView extends LitElement {
|
||||
|
||||
@state() private _selectedViewIdx = 0;
|
||||
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public showDialog(params: SelectViewDialogParams): void {
|
||||
this._config = params.lovelaceConfig;
|
||||
this._urlPath = params.urlPath;
|
||||
this._selectedViewIdx = 0;
|
||||
this._loading = false;
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
if (this._params.allowDashboardChange) {
|
||||
@@ -73,6 +68,8 @@ export class HuiDialogSelectView extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const defaultPanel = getDefaultPanelUrlPath(this.hass);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
@@ -89,19 +86,19 @@ export class HuiDialogSelectView extends LitElement {
|
||||
"ui.panel.lovelace.editor.select_view.dashboard_label"
|
||||
)}
|
||||
.disabled=${!this._dashboards.length}
|
||||
.value=${this._urlPath ?? LOVELACE_PANEL}
|
||||
.value=${this._urlPath || defaultPanel}
|
||||
@selected=${this._dashboardChanged}
|
||||
autofocus
|
||||
.options=${this._dashboards
|
||||
.map((dashboard) => ({
|
||||
value: dashboard.url_path,
|
||||
label: `${dashboard.title}${dashboard.id === LOVELACE_PANEL ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
|
||||
label: `${dashboard.title}${dashboard.id === "lovelace" ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
|
||||
disabled: dashboard.mode !== "storage",
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.value === LOVELACE_PANEL
|
||||
a.value === "lovelace"
|
||||
? -1
|
||||
: b.value === LOVELACE_PANEL
|
||||
: b.value === "lovelace"
|
||||
? 1
|
||||
: a.label.localeCompare(b.label)
|
||||
)}
|
||||
@@ -109,7 +106,54 @@ export class HuiDialogSelectView extends LitElement {
|
||||
</ha-select>`
|
||||
: nothing
|
||||
}
|
||||
${this._renderViews()}
|
||||
${
|
||||
!this._config || (this._config.views || []).length < 1
|
||||
? html`<ha-alert alert-type="error"
|
||||
>${this.hass.localize(
|
||||
this._config
|
||||
? "ui.panel.lovelace.editor.select_view.no_views"
|
||||
: "ui.panel.lovelace.editor.select_view.no_config"
|
||||
)}</ha-alert
|
||||
>`
|
||||
: this._config.views.length > 1
|
||||
? html`
|
||||
<ha-list>
|
||||
${this._config.views.map((view, idx) => {
|
||||
const isStrategy = isStrategyView(view);
|
||||
|
||||
return html`
|
||||
<ha-radio-list-item
|
||||
.graphic=${
|
||||
this._config?.views.some(({ icon }) => icon)
|
||||
? "icon"
|
||||
: nothing
|
||||
}
|
||||
@click=${this._viewChanged}
|
||||
.value=${idx.toString()}
|
||||
.selected=${this._selectedViewIdx === idx}
|
||||
.disabled=${
|
||||
isStrategy && !this._params?.includeStrategyViews
|
||||
}
|
||||
?autofocus=${
|
||||
idx === 0 && !this._params!.allowDashboardChange
|
||||
}
|
||||
>
|
||||
<span>
|
||||
${view.title}${
|
||||
isStrategy
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
|
||||
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
|
||||
</ha-radio-list-item>
|
||||
`;
|
||||
})}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@@ -120,7 +164,7 @@ export class HuiDialogSelectView extends LitElement {
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.disabled=${!this._selectableConfig}
|
||||
.disabled=${!this._config || (this._config.views || []).length < 1}
|
||||
@click=${this._selectView}
|
||||
>
|
||||
${this._params.actionLabel || this.hass!.localize("ui.common.move")}
|
||||
@@ -130,97 +174,30 @@ export class HuiDialogSelectView extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// While a config is loading the views on screen still belong to the
|
||||
// previously selected dashboard, so nothing may be picked from them.
|
||||
private get _selectableConfig(): LovelaceConfig | undefined {
|
||||
return !this._loading && this._config?.views?.length
|
||||
? this._config
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _renderViews() {
|
||||
if (this._loading) {
|
||||
return html`<div class="loading">
|
||||
<ha-spinner size="medium"></ha-spinner>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (!this._selectableConfig) {
|
||||
return html`<ha-alert alert-type="error">
|
||||
${this.hass.localize(
|
||||
this._config
|
||||
? "ui.panel.lovelace.editor.select_view.no_views"
|
||||
: "ui.panel.lovelace.editor.select_view.no_config"
|
||||
)}
|
||||
</ha-alert>`;
|
||||
}
|
||||
|
||||
const views = this._selectableConfig.views;
|
||||
if (views.length < 2) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const hasIcon = views.some(({ icon }) => icon);
|
||||
|
||||
return html`
|
||||
<ha-list>
|
||||
${views.map((view, idx) => {
|
||||
const isStrategy = isStrategyView(view);
|
||||
|
||||
return html`
|
||||
<ha-radio-list-item
|
||||
.graphic=${hasIcon ? "icon" : nothing}
|
||||
@click=${this._viewChanged}
|
||||
.value=${idx.toString()}
|
||||
.selected=${this._selectedViewIdx === idx}
|
||||
.disabled=${isStrategy && !this._params?.includeStrategyViews}
|
||||
?autofocus=${idx === 0 && !this._params!.allowDashboardChange}
|
||||
>
|
||||
<span>
|
||||
${view.title}${
|
||||
isStrategy
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
|
||||
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
|
||||
</ha-radio-list-item>
|
||||
`;
|
||||
})}
|
||||
</ha-list>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _getDashboards() {
|
||||
this._dashboards =
|
||||
this._params!.dashboards || (await fetchDashboards(this.hass));
|
||||
}
|
||||
|
||||
private async _dashboardChanged(ev: ValueChangedEvent<string>) {
|
||||
const urlPath = ev.detail.value === LOVELACE_PANEL ? null : ev.detail.value;
|
||||
let urlPath: string | null = ev.detail.value;
|
||||
if (urlPath === this._urlPath) {
|
||||
return;
|
||||
}
|
||||
if (urlPath === "lovelace") {
|
||||
urlPath = null;
|
||||
}
|
||||
this._urlPath = urlPath;
|
||||
this._loading = true;
|
||||
let config: LovelaceConfig | undefined;
|
||||
this._selectedViewIdx = 0;
|
||||
try {
|
||||
config = (await fetchConfig(
|
||||
this._config = (await fetchConfig(
|
||||
this.hass.connection,
|
||||
urlPath,
|
||||
false
|
||||
)) as LovelaceConfig;
|
||||
} catch (_err: any) {
|
||||
config = undefined;
|
||||
this._config = undefined;
|
||||
}
|
||||
// Responses can resolve out of order.
|
||||
if (urlPath !== this._urlPath) {
|
||||
return;
|
||||
}
|
||||
this._config = config;
|
||||
this._selectedViewIdx = 0;
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
private _viewChanged(e) {
|
||||
@@ -232,14 +209,10 @@ export class HuiDialogSelectView extends LitElement {
|
||||
}
|
||||
|
||||
private _selectView(): void {
|
||||
const config = this._selectableConfig;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "view-selected", { view: this._selectedViewIdx });
|
||||
this._params!.viewSelectedCallback(
|
||||
this._urlPath!,
|
||||
config,
|
||||
this._config!,
|
||||
this._selectedViewIdx
|
||||
);
|
||||
this.closeDialog();
|
||||
@@ -252,10 +225,6 @@ export class HuiDialogSelectView extends LitElement {
|
||||
ha-select {
|
||||
width: 100%;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
mwc-radio-list-item {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
import {
|
||||
isMediaSourceContentId,
|
||||
resolveMediaSourceWithCache,
|
||||
resolveMediaSource,
|
||||
} from "../../../../data/media_source";
|
||||
|
||||
export interface BackgroundConfigTarget {
|
||||
@@ -142,35 +142,21 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
`${(background.opacity ?? 100) / 100}`
|
||||
);
|
||||
|
||||
const backgroundImage = this._currentBackgroundImage();
|
||||
const backgroundImage =
|
||||
typeof background.image === "object"
|
||||
? background.image.media_content_id
|
||||
: background.image;
|
||||
|
||||
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
|
||||
resolveMediaSourceWithCache(this.hass, backgroundImage).then(
|
||||
(result) => {
|
||||
// Discard if the image changed while resolving
|
||||
if (this._currentBackgroundImage() === backgroundImage) {
|
||||
this._resolvedImage = result.url;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (this._currentBackgroundImage() === backgroundImage) {
|
||||
this._resolvedImage = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
resolveMediaSource(this.hass, backgroundImage).then((result) => {
|
||||
this._resolvedImage = result.url;
|
||||
});
|
||||
} else {
|
||||
this._resolvedImage = backgroundImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _currentBackgroundImage(): string | undefined {
|
||||
const background = this._backgroundData(this._config);
|
||||
return typeof background.image === "object"
|
||||
? background.image.media_content_id
|
||||
: background.image;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
|
||||
import {
|
||||
isMediaSourceContentId,
|
||||
resolveMediaSourceWithCache,
|
||||
resolveMediaSource,
|
||||
} from "../../../data/media_source";
|
||||
|
||||
@customElement("hui-view-background")
|
||||
@@ -21,37 +21,20 @@ export class HUIViewBackground extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _getBackgroundImage(
|
||||
background?: string | LovelaceViewBackgroundConfig
|
||||
): string | undefined {
|
||||
if (typeof background === "string") {
|
||||
return background;
|
||||
}
|
||||
if (typeof background?.image === "object") {
|
||||
return background.image.media_content_id;
|
||||
}
|
||||
return background?.image;
|
||||
}
|
||||
private _fetchMedia() {
|
||||
const backgroundImage =
|
||||
typeof this.background === "string"
|
||||
? this.background
|
||||
: typeof this.background?.image === "object"
|
||||
? this.background.image.media_content_id
|
||||
: this.background?.image;
|
||||
|
||||
private async _fetchMedia() {
|
||||
const backgroundImage = this._getBackgroundImage(this.background);
|
||||
|
||||
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
|
||||
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
|
||||
resolveMediaSource(this.hass, backgroundImage).then((result) => {
|
||||
this.resolvedImage = result.url;
|
||||
});
|
||||
} else {
|
||||
this.resolvedImage = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedUrl: string | undefined;
|
||||
try {
|
||||
resolvedUrl = (
|
||||
await resolveMediaSourceWithCache(this.hass, backgroundImage)
|
||||
).url;
|
||||
} catch {
|
||||
resolvedUrl = undefined;
|
||||
}
|
||||
// Discard if the background changed while resolving
|
||||
if (this._getBackgroundImage(this.background) === backgroundImage) {
|
||||
this.resolvedImage = resolvedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +73,10 @@ export class HUIViewBackground extends LitElement {
|
||||
background?: string | LovelaceViewBackgroundConfig
|
||||
) {
|
||||
if (typeof background === "object" && background.image) {
|
||||
const image = this._getBackgroundImage(background) || "";
|
||||
const image =
|
||||
typeof background.image === "object"
|
||||
? background.image.media_content_id || ""
|
||||
: background.image;
|
||||
if (isMediaSourceContentId(image) && !this.resolvedImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
type HASSDomEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import type {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityAlertSeverity,
|
||||
} from "../../../data/frontend";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-control-select-menu";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
|
||||
type SecurityEditorContext = SecurityEntityContext & {
|
||||
language: ContextType<typeof internationalizationContext>["language"];
|
||||
translationMetadata: ContextType<
|
||||
typeof internationalizationContext
|
||||
>["translationMetadata"];
|
||||
};
|
||||
|
||||
@customElement("security-alerts-editor")
|
||||
export class SecurityAlertsEditor extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public alertEntities: SecurityAlertEntityConfig[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
private _entityContext?: SecurityEditorContext;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (
|
||||
changedProps.has("_states") ||
|
||||
changedProps.has("_registries") ||
|
||||
changedProps.has("_i18n")
|
||||
) {
|
||||
this._entityContext = {
|
||||
states: this._states,
|
||||
...this._registries,
|
||||
language: this._i18n.language,
|
||||
translationMetadata: this._i18n.translationMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
|
||||
<div class="alert-list">
|
||||
${repeat(
|
||||
this.alertEntities,
|
||||
(alertEntity) => alertEntity.entity,
|
||||
(alertEntity, index) => this._renderAlertEntity(alertEntity, index)
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<ha-entity-picker
|
||||
add-button
|
||||
.addButtonLabel=${this._i18n.localize(
|
||||
"ui.panel.security.editor.add_alert_entity"
|
||||
)}
|
||||
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
|
||||
.entityFilter=${this._entityFilter}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAlertEntity(
|
||||
alertEntity: SecurityAlertEntityConfig,
|
||||
index: number
|
||||
) {
|
||||
const stateObj = this._states[alertEntity.entity];
|
||||
const { primary, secondary } =
|
||||
stateObj && this._entityContext
|
||||
? computeEntityPickerDisplay(this._entityContext, stateObj)
|
||||
: { primary: alertEntity.entity, secondary: undefined };
|
||||
const severity =
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
|
||||
return html`
|
||||
<div class="alert-row">
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<ha-settings-row slim>
|
||||
<state-badge slot="prefix" .stateObj=${stateObj}></state-badge>
|
||||
<span slot="heading">${primary}</span>
|
||||
${
|
||||
secondary
|
||||
? html`<span slot="description">${secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
<ha-control-select-menu
|
||||
show-arrow
|
||||
hide-label
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.security.editor.severity.label"
|
||||
)}
|
||||
.value=${severity}
|
||||
.options=${(["alert", "warning"] as const).map((option) => ({
|
||||
value: option,
|
||||
label: this._i18n.localize(
|
||||
`ui.panel.security.editor.severity.${option}`
|
||||
),
|
||||
}))}
|
||||
data-index=${index}
|
||||
@wa-select=${this._severityChanged}
|
||||
></ha-control-select-menu>
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this._i18n.localize("ui.common.delete")}
|
||||
data-index=${index}
|
||||
@click=${this._removeClicked}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _changed(next: SecurityAlertEntityConfig[]): void {
|
||||
fireEvent(this, "value-changed", { value: next });
|
||||
}
|
||||
|
||||
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
|
||||
private _getIndex(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement>
|
||||
): number | undefined {
|
||||
const index = Number(ev.currentTarget.dataset.index);
|
||||
return Number.isInteger(index) ? index : undefined;
|
||||
}
|
||||
|
||||
private _removeClicked(ev: HASSDomCurrentTargetEvent<HTMLElement>): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index !== undefined) {
|
||||
const next = [...this.alertEntities];
|
||||
next.splice(index, 1);
|
||||
this._changed(next);
|
||||
}
|
||||
}
|
||||
|
||||
private _severityChanged(
|
||||
ev: HaDropdownSelectEvent<SecurityAlertSeverity> &
|
||||
HASSDomCurrentTargetEvent<HTMLElement>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
const next = [...this.alertEntities];
|
||||
next[index] = { ...next[index], severity: ev.detail.item.value };
|
||||
this._changed(next);
|
||||
}
|
||||
|
||||
private _add(
|
||||
ev: ValueChangedEvent<string | undefined> &
|
||||
HASSDomCurrentTargetEvent<HaEntityPicker>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
const entity = ev.detail.value;
|
||||
if (!entity) return;
|
||||
|
||||
ev.currentTarget.value = "";
|
||||
|
||||
if (
|
||||
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._changed([...this.alertEntities, { entity }]);
|
||||
}
|
||||
|
||||
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const next = [...this.alertEntities];
|
||||
const [moved] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
this._changed(next);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.alert-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.alert-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.handle {
|
||||
cursor: grab;
|
||||
color: var(--secondary-text-color);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
ha-settings-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
gap: var(--ha-space-3);
|
||||
min-height: 48px;
|
||||
--settings-row-prefix-display: contents;
|
||||
--settings-row-content-display: contents;
|
||||
--settings-row-body-padding-top: var(--ha-space-1);
|
||||
--settings-row-body-padding-bottom: var(--ha-space-1);
|
||||
}
|
||||
state-badge {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
--state-icon-color: var(--secondary-text-color);
|
||||
}
|
||||
[slot="heading"],
|
||||
[slot="description"] {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
padding-top: var(--ha-space-3);
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
ha-control-select-menu {
|
||||
width: 118px;
|
||||
--control-select-menu-height: 42px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"security-alerts-editor": SecurityAlertsEditor;
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-expansion-panel";
|
||||
import "../../../components/ha-icon";
|
||||
import type { SecurityFrontendSystemData } from "../../../data/frontend";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import { DialogMixin } from "../../../dialogs/dialog-mixin";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
import "../../../components/entity/ha-favorites-editor";
|
||||
import "../components/security-alerts-editor";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
|
||||
|
||||
@customElement("dialog-edit-security")
|
||||
export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(
|
||||
DialogMixin<EditSecurityDialogParams>(LitElement)
|
||||
) {
|
||||
@state() private _state?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
private _entityContext?: SecurityEntityContext;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("_states") || changedProps.has("_registries")) {
|
||||
this._entityContext = {
|
||||
states: this._states,
|
||||
...this._registries,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!this.params) {
|
||||
return;
|
||||
}
|
||||
this._state = {
|
||||
...this.params.config,
|
||||
favorite_entities: this.params.config.favorite_entities
|
||||
? [...this.params.config.favorite_entities]
|
||||
: [],
|
||||
alert_entities: this.params.config.alert_entities
|
||||
? [...this.params.config.alert_entities]
|
||||
: [],
|
||||
};
|
||||
this._initDirtyTracking({ type: "deep" }, this._state);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params || !this._state) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
width="medium"
|
||||
.headerTitle=${this._i18n.localize("ui.panel.security.editor.title")}
|
||||
.headerSubtitle=${this._i18n.localize(
|
||||
"ui.panel.security.editor.description"
|
||||
)}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
>
|
||||
${this._renderMainEditor()}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
autofocus
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this._i18n.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._submitting || !this.isDirtyState}
|
||||
>
|
||||
${this._i18n.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMainEditor() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
no-collapse
|
||||
.header=${this._i18n.localize(
|
||||
"ui.panel.security.editor.favorite_entities"
|
||||
)}
|
||||
.secondary=${this._i18n.localize(
|
||||
"ui.panel.security.editor.favorite_entities_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<ha-favorites-editor
|
||||
.favorites=${this._state?.favorite_entities ?? []}
|
||||
.entityFilter=${this._entityFilter}
|
||||
.addButtonLabel=${this._i18n.localize(
|
||||
"ui.panel.security.editor.add_favorite_entity"
|
||||
)}
|
||||
@value-changed=${this._favoriteEntitiesChanged}
|
||||
></ha-favorites-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
no-collapse
|
||||
.header=${this._i18n.localize(
|
||||
"ui.panel.security.editor.active_alert_entities"
|
||||
)}
|
||||
.secondary=${this._i18n.localize(
|
||||
"ui.panel.security.editor.active_alert_entities_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:shield-alert-outline"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<security-alerts-editor
|
||||
.alertEntities=${this._state?.alert_entities ?? []}
|
||||
@value-changed=${this._alertEntitiesChanged}
|
||||
></security-alerts-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _alertEntitiesChanged(
|
||||
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state,
|
||||
alert_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _favoriteEntitiesChanged(
|
||||
ev: ValueChangedEvent<SecurityFrontendSystemData["favorite_entities"]>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state,
|
||||
favorite_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this.params || !this._state) return;
|
||||
|
||||
this._submitting = true;
|
||||
|
||||
try {
|
||||
await this.params.saveConfig({
|
||||
...this.params.config,
|
||||
favorite_entities: this._state.favorite_entities?.length
|
||||
? this._state.favorite_entities
|
||||
: undefined,
|
||||
alert_entities: this._state.alert_entities?.length
|
||||
? this._state.alert_entities
|
||||
: undefined,
|
||||
});
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
display: block;
|
||||
--expansion-panel-content-padding: 0;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
--ha-card-border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
ha-expansion-panel + ha-expansion-panel {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.expansion-content {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-edit-security": DialogEditSecurity;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { SecurityFrontendSystemData } from "../../../data/frontend";
|
||||
|
||||
export interface EditSecurityDialogParams {
|
||||
config: SecurityFrontendSystemData;
|
||||
saveConfig: (config: SecurityFrontendSystemData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const loadEditSecurityDialog = () => import("./dialog-edit-security");
|
||||
|
||||
export const showEditSecurityDialog = (
|
||||
element: HTMLElement,
|
||||
params: EditSecurityDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-edit-security",
|
||||
dialogImport: loadEditSecurityDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
@@ -1,24 +1,15 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
type SecurityFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import { showEditSecurityDialog } from "./dialogs/show-dialog-edit-security";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import "../lovelace/views/hui-view-background";
|
||||
@@ -39,16 +30,10 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _loadConfigRevision = 0;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -62,22 +47,13 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (
|
||||
oldHass &&
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// Refresh the generated view when registries or panels change.
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
@@ -87,44 +63,22 @@ class PanelSecurity extends LitElement {
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If ha started, refresh the config
|
||||
if (
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
this._loadConfigPromise,
|
||||
]);
|
||||
await this._setLovelace();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
const revision = ++this._loadConfigRevision;
|
||||
this._config = undefined;
|
||||
try {
|
||||
const data = await fetchFrontendSystemData(
|
||||
this.hass.connection,
|
||||
"security"
|
||||
);
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load security configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.security.editor.load_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
}
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
@@ -143,16 +97,6 @@ class PanelSecurity extends LitElement {
|
||||
.backButton=${this._searchParms.has("historyBack")}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.security")}</div>
|
||||
${
|
||||
this.hass.user?.is_admin && this._config
|
||||
? html`<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.security.editor.title")}
|
||||
@click=${this._editSecurity}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._lovelace
|
||||
? html`
|
||||
@@ -174,18 +118,8 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
{
|
||||
strategy: {
|
||||
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
|
||||
alert_entities: this._config?.alert_entities,
|
||||
favorite_entities: this._config?.favorite_entities,
|
||||
},
|
||||
},
|
||||
SECURITY_LOVELACE_VIEW_CONFIG,
|
||||
this.hass
|
||||
);
|
||||
|
||||
@@ -212,38 +146,6 @@ class PanelSecurity extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _editSecurity = () => {
|
||||
if (!this.hass.user?.is_admin || !this._config) {
|
||||
return;
|
||||
}
|
||||
showEditSecurityDialog(this, {
|
||||
config: this._config,
|
||||
saveConfig: async (config) => {
|
||||
await this._saveConfig(config);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
|
||||
try {
|
||||
await saveFrontendSystemData(this.hass.connection, "security", config);
|
||||
this._config = config;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save security configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.security.editor.save_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.successfully_saved"),
|
||||
});
|
||||
await this._setLovelace();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { registriesContext, statesContext } from "../../data/context";
|
||||
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
|
||||
|
||||
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
|
||||
states: ContextType<typeof statesContext>;
|
||||
};
|
||||
|
||||
export const createSecurityEntityFilter =
|
||||
(getContext: () => SecurityEntityContext | undefined) =>
|
||||
(entity: HassEntity): boolean => {
|
||||
const context = getContext();
|
||||
return context ? isSecurityPanelEntity(context, entity) : false;
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityAlertSeverity,
|
||||
} from "../../../data/frontend";
|
||||
import type { StateCondition } from "../../lovelace/common/validate-condition";
|
||||
import type { AlertCardConfig } from "../../lovelace/cards/types";
|
||||
|
||||
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
|
||||
"carbon_monoxide",
|
||||
"gas",
|
||||
"moisture",
|
||||
"safety",
|
||||
"smoke",
|
||||
] as const;
|
||||
|
||||
type DangerBinarySensorDeviceClass =
|
||||
(typeof DANGER_BINARY_SENSOR_DEVICE_CLASSES)[number];
|
||||
|
||||
const DANGER_BINARY_SENSOR_DEVICE_CLASS_SET =
|
||||
new Set<DangerBinarySensorDeviceClass>(DANGER_BINARY_SENSOR_DEVICE_CLASSES);
|
||||
|
||||
const isDangerBinarySensorDeviceClass = (
|
||||
deviceClass: string
|
||||
): deviceClass is DangerBinarySensorDeviceClass =>
|
||||
DANGER_BINARY_SENSOR_DEVICE_CLASS_SET.has(
|
||||
deviceClass as DangerBinarySensorDeviceClass
|
||||
);
|
||||
|
||||
export const computeDefaultSecurityAlertSeverity = (
|
||||
stateObj?: HassEntity
|
||||
): SecurityAlertSeverity => {
|
||||
if (!stateObj) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
if (domain === "alarm_control_panel") {
|
||||
return "alert";
|
||||
}
|
||||
if (domain === "binary_sensor") {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
return typeof deviceClass === "string" &&
|
||||
isDangerBinarySensorDeviceClass(deviceClass)
|
||||
? "alert"
|
||||
: "warning";
|
||||
}
|
||||
return "warning";
|
||||
};
|
||||
|
||||
export const computeDefaultSecurityAlertVisibility = (
|
||||
entityId: string
|
||||
): StateCondition[] => {
|
||||
const condition: StateCondition = {
|
||||
condition: "state",
|
||||
entity: entityId,
|
||||
};
|
||||
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
condition.state = "triggered";
|
||||
break;
|
||||
case "cover":
|
||||
condition.state = "open";
|
||||
break;
|
||||
case "lock":
|
||||
condition.state = ["jammed", "unlocked", "open"];
|
||||
break;
|
||||
default:
|
||||
condition.state = "on";
|
||||
}
|
||||
|
||||
return [condition];
|
||||
};
|
||||
|
||||
export const computeSecurityAlertCardConfig = (
|
||||
stateObj: HassEntity | undefined,
|
||||
alertEntity: SecurityAlertEntityConfig
|
||||
): AlertCardConfig => {
|
||||
const severity =
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
return {
|
||||
type: "alert",
|
||||
entity: alertEntity.entity,
|
||||
color: severity === "alert" ? "red" : "amber",
|
||||
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
@@ -16,19 +15,12 @@ import type {
|
||||
LovelaceSectionRawConfig,
|
||||
} from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type {
|
||||
LogbookCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../lovelace/cards/types";
|
||||
import type { LogbookCardConfig } from "../../lovelace/cards/types";
|
||||
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
import { computeSecurityAlertCardConfig } from "./security-alerts";
|
||||
|
||||
export interface SecurityViewStrategyConfig {
|
||||
type: "security";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
}
|
||||
|
||||
export const securityEntityFilters: EntityFilter[] = [
|
||||
@@ -77,17 +69,6 @@ export const securityEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const isSecurityPanelEntity = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): boolean =>
|
||||
securityEntityFilters.some((filter) =>
|
||||
generateEntityFilter(hass, filter)(stateObj.entity_id)
|
||||
);
|
||||
|
||||
const processAreasForSecurity = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
@@ -151,7 +132,7 @@ const processUnassignedEntities = (
|
||||
@customElement("security-view-strategy")
|
||||
export class SecurityViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
config: SecurityViewStrategyConfig,
|
||||
_config: SecurityViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const areas = Object.values(hass.areas);
|
||||
@@ -168,37 +149,6 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const entities = findEntities(allEntities, securityFilters);
|
||||
|
||||
const favoriteEntities = (config.favorite_entities ?? []).filter(
|
||||
(entityId) =>
|
||||
hass.states[entityId] &&
|
||||
isSecurityPanelEntity(hass, hass.states[entityId])
|
||||
);
|
||||
|
||||
if (favoriteEntities.length > 0) {
|
||||
sections.push({
|
||||
type: "grid",
|
||||
column_span: 2,
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.favorites"
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...favoriteEntities.map(
|
||||
(entityId) =>
|
||||
({
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
}) satisfies TileCardConfig
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
|
||||
@@ -292,87 +242,37 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const logbookEntityIds = [...entities, ...personEntities];
|
||||
|
||||
const sidebarSections: LovelaceSectionConfig[] = [];
|
||||
|
||||
const alertCards = config.alert_entities?.map((alertEntity) =>
|
||||
computeSecurityAlertCardConfig(
|
||||
hass.states[alertEntity.entity],
|
||||
alertEntity
|
||||
)
|
||||
);
|
||||
|
||||
if (alertCards?.length) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
visibility: [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: alertCards.map((alertCard) => ({
|
||||
condition: "and",
|
||||
conditions: alertCard.visibility!,
|
||||
})),
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.active_alerts"
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...alertCards.map((alertCard) => ({
|
||||
...alertCard,
|
||||
grid_options: { columns: 12 },
|
||||
})),
|
||||
] satisfies LovelaceCardConfig[],
|
||||
});
|
||||
}
|
||||
|
||||
const hasLogbookSection = hasLogbook && logbookEntityIds.length > 0;
|
||||
if (hasLogbookSection) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.activity"
|
||||
),
|
||||
heading_style: "title",
|
||||
} as LovelaceCardConfig,
|
||||
{
|
||||
type: "logbook",
|
||||
target: {
|
||||
entity_id: logbookEntityIds,
|
||||
},
|
||||
hours_to_show: 24,
|
||||
grid_options: { columns: 12 },
|
||||
} satisfies LogbookCardConfig,
|
||||
],
|
||||
});
|
||||
}
|
||||
const sidebarSection: LovelaceSectionConfig | undefined =
|
||||
hasLogbook && logbookEntityIds.length > 0
|
||||
? {
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.activity"
|
||||
),
|
||||
heading_style: "title",
|
||||
} as LovelaceCardConfig,
|
||||
{
|
||||
type: "logbook",
|
||||
target: {
|
||||
entity_id: logbookEntityIds,
|
||||
},
|
||||
hours_to_show: 24,
|
||||
grid_options: { columns: 12 },
|
||||
} satisfies LogbookCardConfig,
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: "sections",
|
||||
max_columns: 3,
|
||||
sections: sections,
|
||||
...(sidebarSections.length > 0 && {
|
||||
...(sidebarSection && {
|
||||
sidebar: {
|
||||
sections: sidebarSections,
|
||||
...(!hasLogbookSection && alertCards?.length
|
||||
? {
|
||||
visibility: [
|
||||
{
|
||||
condition: "or" as const,
|
||||
conditions: alertCards.map((alertCard) => ({
|
||||
condition: "and" as const,
|
||||
conditions: alertCard.visibility!,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
sections: [sidebarSection],
|
||||
content_label: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.devices"
|
||||
),
|
||||
|
||||
+97
-68
@@ -802,10 +802,8 @@
|
||||
"device_not_found": "Device not found",
|
||||
"entity_not_found": "Entity not found",
|
||||
"label_not_found": "Label not found",
|
||||
"device_replaced_headline": "Replaced device",
|
||||
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
"device_replaced_by_one": "Replaced by {device}",
|
||||
"replace_update": "Update",
|
||||
"replace_device": "Replace",
|
||||
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
|
||||
"entities_count_filtered": "{count}/{total} {total, plural,\n one {entity}\n other {entities}\n}",
|
||||
@@ -926,24 +924,16 @@
|
||||
"no_area": "No area",
|
||||
"placeholder": "Select a device",
|
||||
"unknown": "Unknown device selected",
|
||||
"device_replaced": "Replaced device",
|
||||
"device_replaced_by_one": "This device no longer exists. It was replaced by {device}.",
|
||||
"device_replaced_by_one_integration": "This device no longer exists. It was replaced by {device} from {integration}.",
|
||||
"device_replaced_by_multiple": "This device no longer exists. It was replaced by {count} devices, pick the one to use here.",
|
||||
"replace_update": "Update",
|
||||
"replace_choose": "Choose",
|
||||
"device_replaced_count": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
"device_replaced_by_one": "This device was replaced by {device}.",
|
||||
"device_replaced_by_multiple": "This device was replaced by {count} devices.",
|
||||
"replace_device": "Replace",
|
||||
"replaced_dialog": {
|
||||
"title": "Replace device",
|
||||
"description": "This device was replaced by multiple devices. Choose which one to use.",
|
||||
"recommended": "Recommended"
|
||||
}
|
||||
},
|
||||
"device-class-picker": {
|
||||
"device_class": "Device class",
|
||||
"add": "Add device class",
|
||||
"no_device_classes": "No device classes available",
|
||||
"no_match": "No device classes found for {term}"
|
||||
},
|
||||
"category-picker": {
|
||||
"clear": "Clear",
|
||||
"show_categories": "Show categories",
|
||||
@@ -1684,9 +1674,10 @@
|
||||
"person": "Edit person"
|
||||
},
|
||||
"details": "Details",
|
||||
"context": "Context",
|
||||
"entity": "Entity",
|
||||
"floor": "Floor",
|
||||
"entity_id": "Entity ID",
|
||||
"copy_value": "Copy {label}: {value}",
|
||||
"labels": "Labels",
|
||||
"toggle_yaml_mode": "Toggle YAML mode",
|
||||
"translated": "Translated",
|
||||
@@ -2649,25 +2640,6 @@
|
||||
"learn_more": "Learn more"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"editor": {
|
||||
"title": "Edit security and safety page",
|
||||
"description": "Configure your security and safety display preferences.",
|
||||
"favorite_entities": "[%key:ui::panel::lovelace::editor::strategy::home::favorite_entities%]",
|
||||
"favorite_entities_description": "Pin entities to the top of the page.",
|
||||
"add_favorite_entity": "[%key:ui::panel::lovelace::editor::strategy::home::add_favorite_entity%]",
|
||||
"active_alert_entities": "Active alert entities",
|
||||
"active_alert_entities_description": "Display any entities that require attention.",
|
||||
"add_alert_entity": "Add entity",
|
||||
"severity": {
|
||||
"label": "Severity",
|
||||
"alert": "Alert",
|
||||
"warning": "Warning"
|
||||
},
|
||||
"load_failed": "Failed to load security and safety page configuration",
|
||||
"save_failed": "Failed to save security and safety page configuration"
|
||||
}
|
||||
},
|
||||
"my": {
|
||||
"not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.",
|
||||
"component_not_loaded": "This redirect is not supported by your Home Assistant instance. You need the integration {integration} to use this redirect.",
|
||||
@@ -5414,6 +5386,7 @@
|
||||
"any_state_ignore_attributes": "Any state (ignoring attribute changes)",
|
||||
"description": {
|
||||
"picker": "Triggers when the state of an entity (or attribute) changes.",
|
||||
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}",
|
||||
"changed": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n}{anyChange, select, \n true { or any attribute} \n other {}\n} changed{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
|
||||
},
|
||||
"for_type": {
|
||||
@@ -5454,6 +5427,9 @@
|
||||
"type_input": "Value of an entity",
|
||||
"description": {
|
||||
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
|
||||
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"crossed_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above}{duration, select, \n undefined {} \n other { for {duration}}\n}",
|
||||
"crossed_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}",
|
||||
"crossed_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}"
|
||||
@@ -5708,6 +5684,9 @@
|
||||
"value_template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::value_template%]",
|
||||
"description": {
|
||||
"picker": "Tests if the numeric value of an entity's state (or attribute's value) is above or below a given threshold.",
|
||||
"above": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}",
|
||||
"below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}",
|
||||
"above-below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}",
|
||||
"is_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above}",
|
||||
"is_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is below {below}",
|
||||
"is_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above} and below {below}"
|
||||
@@ -5726,6 +5705,8 @@
|
||||
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
|
||||
"description": {
|
||||
"picker": "Tests if an entity (or attribute) is in a specific state.",
|
||||
"no_entity": "If state confirmed",
|
||||
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }",
|
||||
"is": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n} is {states}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
|
||||
}
|
||||
},
|
||||
@@ -6055,7 +6036,7 @@
|
||||
},
|
||||
"check_condition": {
|
||||
"description": {
|
||||
"full": "Test: {condition}"
|
||||
"full": "Test {condition}"
|
||||
}
|
||||
},
|
||||
"set_conversation_response": {
|
||||
@@ -6438,25 +6419,12 @@
|
||||
"description_features": "Control home when away and integrate with Alexa and Google Assistant",
|
||||
"login": {
|
||||
"title": "Cloud login",
|
||||
"landing_title": "[%key:ui::panel::config::cloud::account::onboarding::ha_cloud_title%]",
|
||||
"hero_title": "Tools for your home, support for the Open Home",
|
||||
"hero_lead": "The easy, secure, and private way to get more out of Home Assistant, and the simplest way to fund its development.",
|
||||
"trial_note": "Try free for a month. No payment information required up front.",
|
||||
"partner_note": "This service is run by our partner {nabu_casa_link}, a company founded by the founders of Home Assistant.",
|
||||
"feature_remote_title": "Access from anywhere",
|
||||
"feature_remote_body": "Securely and privately reach your Home Assistant when you're away from home. Zero router configuration, port forwarding, or complex VPNs.",
|
||||
"feature_backup_title": "Backups kept safe",
|
||||
"feature_backup_body": "Your latest Home Assistant backup, automatically stored somewhere safe and only accessible with your private key, ready to restore your full system with one click.",
|
||||
"feature_voice_control_title": "Alexa and Google Home made simple",
|
||||
"feature_voice_control_body": "Ease your transition to Home Assistant, use your Google and Alexa speakers to control every device. Set up in minutes, not hours.",
|
||||
"feature_voice_quality_title": "Fast, accurate voice control",
|
||||
"feature_voice_quality_body": "More languages and higher accuracy for your own private voice assistant powered by Home Assistant.",
|
||||
"feature_companion_title": "Secure app connectivity",
|
||||
"feature_companion_body": "The Home Assistant app on your mobile device forms a secure connection home, encrypting your location and sensor data.",
|
||||
"feature_support_title": "Support Home Assistant",
|
||||
"feature_support_body": "Your subscription directly funds the Open Home Foundation, keeping Home Assistant independent and free from ads or data harvesting.",
|
||||
"introduction": "Home Assistant Cloud provides you with a secure remote connection to your instance while away from home. It also allows you to connect with cloud-only services: Amazon Alexa and Google Assistant.",
|
||||
"introduction2": "This service is run by our partner ",
|
||||
"introduction2a": ", a company founded by the founders of Home Assistant.",
|
||||
"introduction3": "Home Assistant Cloud is a subscription service with a free one-month trial. No payment information necessary.",
|
||||
"learn_more_link": "Learn more about Home Assistant Cloud",
|
||||
"sign_in": "Sign in",
|
||||
"sign_in_lead": "Sign in to your Nabu Casa account.",
|
||||
"email": "Email",
|
||||
"email_error_msg": "Invalid email",
|
||||
"password": "Password",
|
||||
@@ -6466,11 +6434,15 @@
|
||||
"cancel": "Cancel",
|
||||
"submit": "Submit",
|
||||
"forgot_password": "Forgot password?",
|
||||
"start_trial": "Start your free 1-month trial",
|
||||
"trial_info": "No payment information necessary",
|
||||
"alert_password_change_required": "You need to change your password before logging in.",
|
||||
"alert_email_confirm_necessary": "You need to confirm your email before logging in.",
|
||||
"alert_mfa_code_required": "You need to enter your two-factor authentication code.",
|
||||
"alert_mfa_expired_or_not_started": "Multi-factor authentication expired, or not started. Please try again.",
|
||||
"alert_totp_code_invalid": "Invalid two-factor authentication code."
|
||||
"alert_totp_code_invalid": "Invalid two-factor authentication code.",
|
||||
"cloud_pipeline_title": "Want to use Home Assistant Cloud for your voice assistant?",
|
||||
"cloud_pipeline_text": "We created a new assistant for you, using the superior text-to-speech and speech-to-text engines from Home Assistant Cloud. Would you like to set this assistant as the preferred assistant?"
|
||||
},
|
||||
"forgot_password": {
|
||||
"title": "Forgot password",
|
||||
@@ -6482,9 +6454,17 @@
|
||||
"check_your_email": "Check your email for instructions on how to reset your password."
|
||||
},
|
||||
"register": {
|
||||
"headline": "Start free trial",
|
||||
"title": "Register account",
|
||||
"headline": "Start your free trial",
|
||||
"information": "Create an account to start your free one-month trial with Home Assistant Cloud. No payment information necessary.",
|
||||
"terms_notice": "By creating an account, you agree to our {terms} and {privacy_policy}.",
|
||||
"information2": "The trial will give you access to all the benefits of Home Assistant Cloud, including:",
|
||||
"feature_remote_control": "Control of Home Assistant away from home",
|
||||
"feature_google_home": "Integration with Google Assistant",
|
||||
"feature_amazon_alexa": "Integration with Amazon Alexa",
|
||||
"feature_webhook_apps": "Easy integration with webhook-based apps like OwnTracks",
|
||||
"information3": "This service is run by our partner ",
|
||||
"information3a": ", a company founded by the founders of Home Assistant.",
|
||||
"information4": "By registering an account, you agree to the following terms and conditions.",
|
||||
"link_terms_conditions": "Terms and conditions",
|
||||
"link_privacy_policy": "Privacy policy",
|
||||
"create_account": "Create account",
|
||||
@@ -6493,17 +6473,10 @@
|
||||
"password": "Password",
|
||||
"password_error_msg": "Passwords are at least 8 characters",
|
||||
"start_trial": "Start trial",
|
||||
"sign_in_instead": "Sign in instead",
|
||||
"check_your_email": "Check your email",
|
||||
"confirm_email_body": "Open the confirmation link in the email we just sent to {email}.",
|
||||
"waiting_for_confirmation": "Waiting for confirmation. We'll sign you in automatically.",
|
||||
"checking_confirmation": "Checking for your confirmation…",
|
||||
"auto_login_failed": "We couldn't sign you in automatically. Confirm your email, then sign in.",
|
||||
"nothing_arrived": "Nothing arrived? Check your spam folder, or {resend_link}.",
|
||||
"resend_link": "resend the confirmation email",
|
||||
"resend_confirm_email": "Resend confirmation email",
|
||||
"clicked_confirm": "I opened the confirmation link",
|
||||
"confirm_email": "Check the email we just sent to {email} and open the confirmation link to continue.",
|
||||
"account_created": "Account created! Check your email for instructions on how to activate your account.",
|
||||
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
|
||||
},
|
||||
"account": {
|
||||
@@ -6983,7 +6956,63 @@
|
||||
"esphome": {
|
||||
"show_encryption_key": "Show encryption key",
|
||||
"encryption_key_title": "ESPHome Encryption Key",
|
||||
"encryption_key_description": "This is the encryption key for your ESPHome device. Keep it in a safe place, as you may need it when transferring devices between Home Assistant instances."
|
||||
"encryption_key_description": "This is the encryption key for your ESPHome device. Keep it in a safe place, as you may need it when transferring devices between Home Assistant instances.",
|
||||
"setup_title": "Set up your ESPHome proxy",
|
||||
"setup_continue_title": "Continue setup for {name}",
|
||||
"setup_intro": "This adapter unlocks {count} simultaneous {count, plural,\n one {capability}\n other {capabilities}\n} in Home Assistant. Set them up now, or come back to this checklist any time.",
|
||||
"setup_action": "Set up",
|
||||
"setup_later": "Later",
|
||||
"setup_learn_more": "Learn more",
|
||||
"setup_reminder_remaining": "Set up {count} more {count, plural,\n one {capability}\n other {capabilities}\n}",
|
||||
"setup_reminder_done": "Everything is set up",
|
||||
"setup_reminder_intro": "This adapter unlocks {count} simultaneous {count, plural,\n one {capability}\n other {capabilities}\n} in Home Assistant.",
|
||||
"setup_reminder_done_intro": "All {count} simultaneous {count, plural,\n one {capability is}\n other {capabilities are}\n} configured.",
|
||||
"setup_capability_bluetooth_title": "Bluetooth proxy",
|
||||
"setup_capability_bluetooth_short": "Extend Bluetooth range",
|
||||
"setup_capability_bluetooth_description": "Add nearby Bluetooth devices into Home Assistant. Adding multiple Bluetooth proxies allows for better connectivity with devices across your home.",
|
||||
"setup_capability_audio_title": "Stream audio via Sendspin",
|
||||
"setup_capability_audio_short": "Multi-room sound, recommended with Music Assistant",
|
||||
"setup_capability_audio_description": "Stream audio from Home Assistant (including announcements, media, or radio); just note this audio will be unsynchronized with other players. For synchronized whole-home audio, use Music Assistant.",
|
||||
"setup_capability_connectivity_title": "Connect a Zigbee or Z-Wave adapter",
|
||||
"setup_capability_connectivity_short": "Network connectivity for ZBT-2 or ZWA-2",
|
||||
"setup_capability_connectivity_description": "Plug a Connect ZBT-2 or ZWA-2 into the USB port to enable Zigbee or Z-Wave connectivity over Ethernet or Wi-Fi. Get your antenna in an optimal location for better device responsiveness.",
|
||||
"setup_capability_serial_title": "Control a device over serial",
|
||||
"setup_capability_serial_short": "AV receivers, amplifiers, and more",
|
||||
"setup_capability_serial_description": "Add serial devices into Home Assistant, allowing for control of A/V receivers and multi-zone amplifiers.",
|
||||
"setup_audio_home_assistant": "Home Assistant",
|
||||
"setup_audio_builtin_playback": "Built-in playback",
|
||||
"setup_audio_music_assistant": "Music Assistant",
|
||||
"setup_audio_music_assistant_meta": "Synchronized multi-room audio",
|
||||
"setup_audio_music_assistant_upsell": "The better way to stream with a free app",
|
||||
"setup_audio_benefit_multiroom": "Synchronized multi-room playback",
|
||||
"setup_audio_benefit_lossless": "Lossless, high-resolution audio",
|
||||
"setup_audio_benefit_album_art": "Album art on every screen",
|
||||
"setup_audio_active": "Active",
|
||||
"setup_audio_recommended": "Recommended",
|
||||
"setup_install_music_assistant": "Install Music Assistant",
|
||||
"setup_open_music_assistant": "Open Music Assistant",
|
||||
"setup_installing_music_assistant": "Installing Music Assistant",
|
||||
"setup_starting_music_assistant": "Starting Music Assistant",
|
||||
"setup_discovering_music_assistant": "Waiting for Music Assistant discovery",
|
||||
"setup_error_music_assistant": "Couldn't install Music Assistant",
|
||||
"setup_error_capabilities": "Couldn't load device capabilities",
|
||||
"setup_error_defer": "Couldn't save setup for later",
|
||||
"setup_error_serial_integration": "Couldn't start this integration",
|
||||
"setup_status_completed": "Completed",
|
||||
"setup_status_active": "Active",
|
||||
"setup_zwave": "Set up Z-Wave",
|
||||
"setup_what_are_adapters": "What are these adapters?",
|
||||
"setup_adapters_title": "Add Zigbee or Z-Wave",
|
||||
"setup_adapters_intro": "Plug a Home Assistant Connect adapter into your proxy's USB port to add wireless radios:",
|
||||
"setup_adapter_zbt2": "Connect ZBT-2",
|
||||
"setup_adapter_zbt2_description": "Adds a Zigbee radio.",
|
||||
"setup_adapter_zwa2": "Connect ZWA-2",
|
||||
"setup_adapter_zwa2_description": "Adds a Z-Wave radio.",
|
||||
"setup_serial_choose": "Choose an integration",
|
||||
"setup_serial_intro": "Choose an integration to set up a serial device on this ESPHome device. The integration will let you pick the serial port.",
|
||||
"setup_serial_ports": "Ports on this device",
|
||||
"setup_serial_configured": "Configured",
|
||||
"setup_serial_integrations": "Serial integrations"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
@@ -9188,8 +9217,6 @@
|
||||
"security": {
|
||||
"devices": "Devices",
|
||||
"other_devices": "Other devices",
|
||||
"active_alerts": "Active alerts",
|
||||
"favorites": "[%key:ui::panel::lovelace::strategy::home::favorites%]",
|
||||
"activity": "Activity"
|
||||
},
|
||||
"climate": {
|
||||
@@ -11770,6 +11797,8 @@
|
||||
"cancel": "[%key:ui::panel::config::cloud::login::cancel%]",
|
||||
"submit": "[%key:ui::panel::config::cloud::login::submit%]",
|
||||
"forgot_password": "[%key:ui::panel::config::cloud::login::forgot_password%]",
|
||||
"start_trial": "[%key:ui::panel::config::cloud::login::start_trial%]",
|
||||
"trial_info": "[%key:ui::panel::config::cloud::login::trial_info%]",
|
||||
"alert_password_change_required": "[%key:ui::panel::config::cloud::login::alert_password_change_required%]",
|
||||
"alert_email_confirm_necessary": "[%key:ui::panel::config::cloud::login::alert_email_confirm_necessary%]",
|
||||
"alert_mfa_code_required": "[%key:ui::panel::config::cloud::login::alert_mfa_code_required%]",
|
||||
|
||||
@@ -17,24 +17,10 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
|
||||
return points;
|
||||
};
|
||||
|
||||
// The chart data modules break the line with a null value. A handful of them
|
||||
// stands for an entity that went unavailable; a series that is mostly null
|
||||
// stands for the climate heating dataset, which emits one per inactive state.
|
||||
const withGaps = (
|
||||
points: [number, number][],
|
||||
isGap: (index: number) => boolean
|
||||
): [number, number | null][] =>
|
||||
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
|
||||
|
||||
const small = generatePoints(1, SCALES.small);
|
||||
const medium = generatePoints(2, SCALES.medium);
|
||||
const large = generatePoints(3, SCALES.large);
|
||||
const largeObjects = large.map((value) => ({ value }));
|
||||
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
|
||||
const largeMostlyGaps = withGaps(
|
||||
large,
|
||||
(index) => Math.floor(index / 50) % 3 !== 0
|
||||
);
|
||||
|
||||
describe("downSampleLineData", () => {
|
||||
bench("min/max small (1k points)", () => {
|
||||
@@ -68,20 +54,4 @@ describe("downSampleLineData", () => {
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
|
||||
bench(
|
||||
"min/max large with a few gaps (100k points)",
|
||||
() => {
|
||||
downSampleLineData(largeFewGaps, MAX_DETAILS);
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
|
||||
bench(
|
||||
"min/max large mostly gaps (100k points)",
|
||||
() => {
|
||||
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LeafletModuleType } from "../../../src/common/dom/setup-leaflet-map";
|
||||
|
||||
// The fallback to raster tiles is what keeps the map working on devices
|
||||
// without WebGL2, on instances that cannot load the MapLibre chunk, and when
|
||||
// building the MapLibre map itself fails - a blocked worker, an exhausted
|
||||
// WebGL context budget. None of that is reachable from the `ha-map` tests,
|
||||
// which run in jsdom and therefore only ever take the raster branch.
|
||||
|
||||
const maplibreLayer = vi.hoisted(() => ({
|
||||
addTo: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
options: {} as { attribution?: string },
|
||||
getMaplibreMap: vi.fn(),
|
||||
}));
|
||||
|
||||
const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
|
||||
|
||||
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
|
||||
|
||||
const STYLE = {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }],
|
||||
};
|
||||
|
||||
const rasterLayer = { addTo: vi.fn() };
|
||||
// Kept as a local so tests can flip it: @types/leaflet has it readonly.
|
||||
const browser = { retina: false };
|
||||
const leaflet = {
|
||||
tileLayer: vi.fn(() => rasterLayer),
|
||||
Browser: browser,
|
||||
} as unknown as LeafletModuleType;
|
||||
|
||||
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
|
||||
const glHandlers: Record<string, () => void> = {};
|
||||
const glMap = {
|
||||
setStyle: vi.fn(),
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
glHandlers[event] = handler;
|
||||
}),
|
||||
};
|
||||
const map = { on: vi.fn() } as any;
|
||||
|
||||
// The WebGL2 probe is cached for the lifetime of the module, so every test
|
||||
// needs its own copy of it.
|
||||
const setWebGL2 = async (supported: boolean) => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() =>
|
||||
supported ? ({ getExtension: () => null } as any) : null
|
||||
);
|
||||
return (await import("../../../src/common/map/base-layer")).createBaseLayer;
|
||||
};
|
||||
|
||||
const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
maplibreLayer.options = {};
|
||||
maplibreGL.mockReturnValue(maplibreLayer);
|
||||
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
|
||||
maplibreLayer.getMaplibreMap.mockReturnValue(glMap);
|
||||
for (const event of Object.keys(glHandlers)) {
|
||||
delete glHandlers[event];
|
||||
}
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("createBaseLayer", () => {
|
||||
it("falls back to raster tiles without WebGL2", async () => {
|
||||
const createBaseLayer = await setWebGL2(false);
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
expect(maplibreGL).not.toHaveBeenCalled();
|
||||
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
|
||||
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
|
||||
// No referrer: the only one a browser can send is its origin, which
|
||||
// identifies a Nabu Casa installation. The fallback source is chosen so it
|
||||
// does not need one.
|
||||
expect(options).not.toHaveProperty("referrerPolicy");
|
||||
// The vector layer takes its credit from the style's source instead, so the
|
||||
// raster layer is the only one carrying attribution itself - and it credits
|
||||
// both the data and whoever rendered it.
|
||||
expect(options.attribution).toContain("openstreetmap.org/copyright");
|
||||
expect(options.attribution).toContain("carto.com/attributions");
|
||||
expect(url).toMatch(/\{z\}\/\{x\}\/\{y\}/);
|
||||
});
|
||||
|
||||
// The devices on the fallback are the old retina tablets, and the source
|
||||
// serves @2x, so they get sharp tiles without quadrupling the requests.
|
||||
it("asks for @2x raster tiles on a retina screen", async () => {
|
||||
const createBaseLayer = await setWebGL2(false);
|
||||
browser.retina = true;
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
expect(vi.mocked(leaflet.tileLayer).mock.calls[0][0]).toContain("@2x.png");
|
||||
browser.retina = false;
|
||||
});
|
||||
|
||||
it("uses vector tiles when WebGL2 is available", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
expect(maplibreGL).toHaveBeenCalledOnce();
|
||||
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
|
||||
expect(leaflet.tileLayer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to raster tiles when the style cannot be fetched", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
})
|
||||
);
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
});
|
||||
|
||||
// The plugin only builds the MapLibre map once the layer is added, so this
|
||||
// is where a blocked worker or a refused WebGL context surfaces.
|
||||
it("falls back to raster tiles when adding the vector layer throws", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
maplibreLayer.addTo.mockImplementation(() => {
|
||||
throw new Error("Failed to initialize WebGL");
|
||||
});
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
expect(maplibreLayer.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still falls back when tearing down the half-added layer throws", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
maplibreLayer.addTo.mockImplementation(() => {
|
||||
throw new Error("Failed to initialize WebGL");
|
||||
});
|
||||
maplibreLayer.remove.mockImplementation(() => {
|
||||
throw new Error("nothing to remove");
|
||||
});
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setDarkMode", () => {
|
||||
beforeEach(() => {
|
||||
glMap.setStyle.mockClear();
|
||||
});
|
||||
|
||||
it("swaps the style, and ignores a repeat of the current mode", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false);
|
||||
|
||||
baseLayer.setDarkMode(true);
|
||||
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
|
||||
|
||||
baseLayer.setDarkMode(true);
|
||||
expect(glMap.setStyle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// A failed request must roll back to the style that is actually on screen.
|
||||
// Rolling back to the opposite of the failed request instead would desync
|
||||
// the tracked mode, and the next toggle to that mode would do nothing.
|
||||
it("can retry a mode whose request failed while another was in flight", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false);
|
||||
|
||||
const failing = vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
});
|
||||
vi.stubGlobal("fetch", failing);
|
||||
|
||||
// Dark is superseded by light, which then fails: the map is still light.
|
||||
baseLayer.setDarkMode(true);
|
||||
baseLayer.setDarkMode(false);
|
||||
await vi.waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
|
||||
// Both rejections have to land before the retry, or this passes whatever
|
||||
// the rollback does.
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
|
||||
);
|
||||
baseLayer.setDarkMode(true);
|
||||
|
||||
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
// Styles are fetched, so a burst of theme changes can resolve out of order.
|
||||
// Applying a stale one would leave the map on the wrong theme for good.
|
||||
it("ignores a style that resolves after a newer request", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
const resolvers: ((value: unknown) => void)[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const styleResponse = (name: string) => ({
|
||||
json: async () => ({ ...structuredClone(STYLE), name }),
|
||||
});
|
||||
|
||||
// The layer only settles once its first style resolves, so let that one
|
||||
// through before the map exists to switch.
|
||||
const pending = createBaseLayer(leaflet, map, false);
|
||||
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
|
||||
resolvers.shift()!(styleResponse("light"));
|
||||
const baseLayer = await pending;
|
||||
|
||||
baseLayer.setDarkMode(true);
|
||||
baseLayer.setDarkMode(false);
|
||||
await vi.waitFor(() => expect(resolvers).toHaveLength(2));
|
||||
|
||||
// The dark request, which is no longer the newest, comes back last.
|
||||
resolvers[1](styleResponse("light"));
|
||||
resolvers[0](styleResponse("dark"));
|
||||
|
||||
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
|
||||
expect(glMap.setStyle.mock.calls[0][0]).toMatchObject({ name: "light" });
|
||||
});
|
||||
});
|
||||
|
||||
// Browsers cap the number of live WebGL contexts and drop the oldest. With
|
||||
// more map cards than that cap - measured at 16 in Chrome - the first cards
|
||||
// lose their context and never get it back, because nothing frees a slot.
|
||||
describe("WebGL context loss", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("falls back to raster tiles when the context stays lost", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
expect(leaflet.tileLayer).not.toHaveBeenCalled();
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(maplibreLayer.remove).toHaveBeenCalled();
|
||||
expect(isRaster()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the vector layer when the context comes back", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
glHandlers.webglcontextrestored();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(maplibreLayer.remove).not.toHaveBeenCalled();
|
||||
expect(leaflet.tileLayer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Backgrounding a tab loses the context too, and there it comes back when
|
||||
// the page is shown again. Running the clock anyway would mean switching
|
||||
// apps for a few seconds was enough to come back to a raster map.
|
||||
it("waits for the page to be visible before falling back", async () => {
|
||||
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
expect(leaflet.tileLayer).not.toHaveBeenCalled();
|
||||
|
||||
hidden.mockReturnValue(false);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the vector layer when a hidden page gets its context back", async () => {
|
||||
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
glHandlers.webglcontextrestored();
|
||||
|
||||
hidden.mockReturnValue(false);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(leaflet.tileLayer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops listening for visibility once it has fallen back", async () => {
|
||||
const remove = vi.spyOn(document, "removeEventListener");
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(remove).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function)
|
||||
);
|
||||
// And a later visibility change must not add a second raster layer.
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
vi.runAllTimers();
|
||||
expect(isRaster()).toBe(true);
|
||||
});
|
||||
|
||||
it("stops answering theme changes once it has fallen back", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
baseLayer.setDarkMode(true);
|
||||
|
||||
expect(glMap.setStyle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,52 +19,9 @@ const generatePoints = (
|
||||
return points;
|
||||
};
|
||||
|
||||
// Gap markers: the chart data modules push a null value to break the line
|
||||
// where an entity was unavailable.
|
||||
type GappedPoint = [number, number | null | undefined];
|
||||
|
||||
const toObjectPoints = (points: GappedPoint[]) =>
|
||||
const toObjectPoints = (points: [number, number][]) =>
|
||||
points.map((value) => ({ value }));
|
||||
|
||||
const expectXOrdered = (result: { [0]: number }[]) => {
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
|
||||
}
|
||||
};
|
||||
|
||||
// A series whose readings are all positive, with an unavailable stretch that
|
||||
// starts inside the first frame. Mirrors the point sequence
|
||||
// state-history-chart-line-data.ts emits for a gap.
|
||||
const gappedPoints: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90], // frame maximum
|
||||
[FIXED_EPOCH_MS + 2_000, 60],
|
||||
[FIXED_EPOCH_MS + 3_000, 10], // frame minimum
|
||||
[FIXED_EPOCH_MS + 4_000, 20], // last reading before the gap
|
||||
[FIXED_EPOCH_MS + 4_001, null], // gap marker
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
|
||||
// A generated series with three unavailable stretches of different lengths.
|
||||
const generateGappedPoints = (seed: number, count: number) => {
|
||||
const points: GappedPoint[] = generatePoints(seed, count);
|
||||
for (const [start, length] of [
|
||||
[Math.floor(count * 0.13), 3],
|
||||
[Math.floor(count * 0.4), 25],
|
||||
[Math.floor(count * 0.83), 1],
|
||||
]) {
|
||||
const gapStart = points[start][0];
|
||||
points.splice(
|
||||
start + 1,
|
||||
length,
|
||||
[gapStart + 1, points[start][1]],
|
||||
[gapStart + 1, null]
|
||||
);
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
describe("downSampleLineData", () => {
|
||||
it("returns empty array for undefined data", () => {
|
||||
expect(downSampleLineData(undefined, 100)).toEqual([]);
|
||||
@@ -109,7 +66,11 @@ describe("downSampleLineData", () => {
|
||||
});
|
||||
|
||||
it("min/max mode preserves x-order for sorted input", () => {
|
||||
expectXOrdered(downSampleLineData(generatePoints(4, 1000), 50));
|
||||
const points = generatePoints(4, 1000);
|
||||
const result = downSampleLineData(points, 50);
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
|
||||
}
|
||||
});
|
||||
|
||||
it("min/max mode matches characterization snapshot", () => {
|
||||
@@ -232,165 +193,6 @@ describe("downSampleLineData", () => {
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("keeps the frame minimum when a gap marker shares the frame", () => {
|
||||
// Without special handling the marker becomes y=0, wins the minimum slot
|
||||
// and the real minimum (10) is dropped.
|
||||
expect(downSampleLineData(gappedPoints, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 3_000, 10],
|
||||
[FIXED_EPOCH_MS + 4_001, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a marker whose gap closes within the same frame", () => {
|
||||
// A frame spans about one device pixel, so a gap that opens and closes
|
||||
// inside one is too narrow to show. The values around it stay.
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 10], // frame minimum, before the marker
|
||||
[FIXED_EPOCH_MS + 1_001, null],
|
||||
[FIXED_EPOCH_MS + 2_000, 90], // frame maximum, after the marker
|
||||
[FIXED_EPOCH_MS + 3_000, 60],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 10],
|
||||
[FIXED_EPOCH_MS + 2_000, 90],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a marker sharing its x with a value after that value", () => {
|
||||
// statistics-chart-data.ts ends the line and breaks it at the same x
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 2_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 2_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a gap marker whose frame holds no values", () => {
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 60],
|
||||
[FIXED_EPOCH_MS + 15_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 15_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a single gap marker per frame", () => {
|
||||
// A run of nulls inside one frame renders the same as a single null: the
|
||||
// break only depends on which points the marker sits between.
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 3_000, null],
|
||||
[FIXED_EPOCH_MS + 4_000, null],
|
||||
[FIXED_EPOCH_MS + 5_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 5_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds the output on a series where most points are null", () => {
|
||||
// The climate heating/cooling datasets push a null for every state where
|
||||
// the mode is inactive, so markers must not escape the frame budget.
|
||||
const values = generatePoints(20, SCALES.medium);
|
||||
const random = createSeededRandom(21);
|
||||
const gapped: GappedPoint[] = values.map(([x, y]) =>
|
||||
random() < 0.65 ? [x, null] : [x, y]
|
||||
);
|
||||
const gapless = downSampleLineData(values, 500);
|
||||
const result = downSampleLineData(gapped, 500);
|
||||
// Both series share an x grid, so they share frames, and the gapless one
|
||||
// emits at least one point per frame. A gapped frame emits at most three:
|
||||
// min, max and a single marker.
|
||||
expect(result.length).toBeLessThanOrEqual(3 * gapless.length);
|
||||
expect(
|
||||
result.filter((point) => point[1] === null).length
|
||||
).toBeLessThanOrEqual(gapless.length);
|
||||
});
|
||||
|
||||
it("handles gap markers on object-shaped points", () => {
|
||||
const points = toObjectPoints(gappedPoints);
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
points[1],
|
||||
points[3],
|
||||
points[5],
|
||||
points[6],
|
||||
points[7],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles gap markers on Date x values", () => {
|
||||
// statistics charts use Date objects for x
|
||||
const points = gappedPoints.map(
|
||||
([x, y]) => [new Date(x), y] as [Date, number | null | undefined]
|
||||
);
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
points[1],
|
||||
points[3],
|
||||
points[5],
|
||||
points[6],
|
||||
points[7],
|
||||
]);
|
||||
});
|
||||
|
||||
it("mean mode leaves gap markers out of the average", () => {
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 10],
|
||||
[FIXED_EPOCH_MS + 1_000, 20],
|
||||
[FIXED_EPOCH_MS + 1_001, null],
|
||||
[FIXED_EPOCH_MS + 2_000, 30],
|
||||
[FIXED_EPOCH_MS + 30_000, 100],
|
||||
[FIXED_EPOCH_MS + 31_000, 100],
|
||||
];
|
||||
expect(downSampleLineData(points, 3, undefined, undefined, true)).toEqual([
|
||||
// (10 + 20 + 30) / 3, not (10 + 20 + 0 + 30) / 4
|
||||
[FIXED_EPOCH_MS + 1_000, 20],
|
||||
[FIXED_EPOCH_MS + 30_500, 100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("min/max mode preserves x-order for gapped input", () => {
|
||||
const result = downSampleLineData(generateGappedPoints(22, 1000), 50);
|
||||
// Of the three gaps only the 25 point one outlasts its frame; the one and
|
||||
// three point gaps close within theirs and are dropped.
|
||||
expect(result.filter((point) => point[1] === null)).toHaveLength(1);
|
||||
expectXOrdered(result);
|
||||
});
|
||||
|
||||
it("large scale mean-mode digest is stable", () => {
|
||||
expect(
|
||||
digestResult(
|
||||
|
||||
@@ -71,6 +71,30 @@ describe("ha-control-slider display rounding", () => {
|
||||
// stepped percentage such as 29 snaps to 26 * step = 28.5714…
|
||||
const FAN_STEP = 100 / 91;
|
||||
|
||||
const ariaValueNow = (el: HaControlSlider) =>
|
||||
el
|
||||
.shadowRoot!.querySelector('[role="slider"]')!
|
||||
.getAttribute("aria-valuenow");
|
||||
|
||||
const tooltipText = (el: HaControlSlider) =>
|
||||
el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim();
|
||||
|
||||
it("shows the fractional stepped value by default", async () => {
|
||||
const el = await mountSlider({ step: FAN_STEP, value: 29 });
|
||||
expect(tooltipText(el)).toBe("28.57");
|
||||
expect(ariaValueNow(el)).toBe(el.steppedValue(29).toString());
|
||||
});
|
||||
|
||||
it("rounds the displayed value to an integer when round-value is set", async () => {
|
||||
const el = await mountSlider({
|
||||
step: FAN_STEP,
|
||||
value: 29,
|
||||
roundValue: true,
|
||||
});
|
||||
expect(tooltipText(el)).toBe("29");
|
||||
expect(ariaValueNow(el)).toBe("29");
|
||||
});
|
||||
|
||||
it("still snaps to the real step grid when rounding the display", async () => {
|
||||
const el = await mountSlider({
|
||||
step: FAN_STEP,
|
||||
@@ -81,6 +105,13 @@ describe("ha-control-slider display rounding", () => {
|
||||
// the number of speed steps (and keyboard granularity) is preserved.
|
||||
expect(el.steppedValue(29)).toBeCloseTo(28.5714, 3);
|
||||
});
|
||||
|
||||
it("keeps decimal steps intact unless round-value is set", async () => {
|
||||
// A temperature-style slider must keep showing halves.
|
||||
const el = await mountSlider({ step: 0.5, value: 21.5 });
|
||||
expect(tooltipText(el)).toBe("21.5");
|
||||
expect(ariaValueNow(el)).toBe("21.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ha-control-slider step bounds", () => {
|
||||
@@ -126,6 +157,22 @@ describe("ha-control-slider step bounds", () => {
|
||||
expect(el.steppedValue(el.percentageToValue(1))).toBe(100);
|
||||
});
|
||||
|
||||
it("shows and announces the bound, not the overshoot", async () => {
|
||||
const el = await mountSlider({
|
||||
...RANGE,
|
||||
value: 99,
|
||||
tooltipMode: "always",
|
||||
});
|
||||
expect(el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim()).toBe(
|
||||
"99"
|
||||
);
|
||||
expect(
|
||||
el
|
||||
.shadowRoot!.querySelector('[role="slider"]')!
|
||||
.getAttribute("aria-valuenow")
|
||||
).toBe("99");
|
||||
});
|
||||
|
||||
it("keeps paging inside the bounds", async () => {
|
||||
const el = await mountSlider({ ...RANGE, value: 91 });
|
||||
const values = changedValues(el);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { html, nothing, render } from "lit";
|
||||
import "../../src/components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableRowData,
|
||||
HaDataTable,
|
||||
} from "../../src/components/data-table/ha-data-table";
|
||||
|
||||
const columns: DataTableColumnContainer = {
|
||||
name: { title: "Name", main: true },
|
||||
area: { title: "Area" },
|
||||
category: { title: "Category" },
|
||||
empty_template: { title: "Empty", template: () => nothing },
|
||||
filled_template: { title: "Filled", template: () => html`filled` },
|
||||
};
|
||||
|
||||
// The narrow row puts every non-main column on a secondary line, joined by dots.
|
||||
const renderNarrowSecondary = (row: DataTableRowData) => {
|
||||
const el = document.createElement("ha-data-table") as HaDataTable;
|
||||
const container = document.createElement("div");
|
||||
render((el as any)._renderRow(columns, true, row, 0), container);
|
||||
return container.querySelector(".secondary")!.textContent!.trim();
|
||||
};
|
||||
|
||||
describe("ha-data-table narrow secondary line", () => {
|
||||
it("does not render separators for empty columns", () => {
|
||||
expect(renderNarrowSecondary({ id: "1", name: "Test" })).toBe("filled");
|
||||
});
|
||||
|
||||
it("separates only the columns that have a value", () => {
|
||||
expect(
|
||||
renderNarrowSecondary({ id: "1", name: "Test", area: "Kitchen" })
|
||||
).toBe("Kitchen · filled");
|
||||
});
|
||||
|
||||
it("renders a blank secondary line when all secondary columns are empty", () => {
|
||||
const emptyColumns: DataTableColumnContainer = {
|
||||
name: { title: "Name", main: true },
|
||||
area: { title: "Area" },
|
||||
category: { title: "Category" },
|
||||
empty_template: { title: "Empty", template: () => nothing },
|
||||
};
|
||||
const el = document.createElement("ha-data-table") as HaDataTable;
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
(el as any)._renderRow(emptyColumns, true, { id: "1", name: "Test" }, 0),
|
||||
container
|
||||
);
|
||||
expect(container.querySelector(".secondary")!.textContent!.trim()).toBe("");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user