mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 11:58:45 +00:00
Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a306cf4687 | ||
|
|
6ad4fb6161 | ||
|
|
03b01e3b05 | ||
|
|
510ba20b59 | ||
|
|
50b80585f3 | ||
|
|
9bffae3ee4 | ||
|
|
a287aacd98 | ||
|
|
b29b0bd083 | ||
|
|
ae8aa3813b | ||
|
|
770caf62f7 | ||
|
|
8fed3ac0c8 | ||
|
|
98f5bdd703 | ||
|
|
8e13ba872d | ||
|
|
37433c787f | ||
|
|
dc7ee8dc5a | ||
|
|
ddc99501af | ||
|
|
5ec87269dc | ||
|
|
53cf986491 | ||
|
|
0388cb8cd0 | ||
|
|
cfefa39b84 | ||
|
|
5a907614af | ||
|
|
c325392e79 | ||
|
|
241b8c5771 | ||
|
|
06e55141f5 | ||
|
|
f6751a8c44 | ||
|
|
1fb138337a | ||
|
|
50df620a01 | ||
|
|
e581172474 | ||
|
|
59f16eeca2 | ||
|
|
1cda8ee42d | ||
|
|
87a4a70352 | ||
|
|
046cf4de14 | ||
|
|
d0d5b93106 | ||
|
|
3f2c0dae1b | ||
|
|
5dea28504e | ||
|
|
8e1c4e93cf | ||
|
|
5513abbe8f | ||
|
|
a9862a8f7c | ||
|
|
d23314f4ff | ||
|
|
fcc5b381d3 | ||
|
|
564205a3f3 | ||
|
|
86fb936d14 | ||
|
|
497b7fce49 | ||
|
|
251aac7e2e | ||
|
|
8d2106ccc8 | ||
|
|
3de2637da7 | ||
|
|
36d048c8ab | ||
|
|
4d7d5231d6 | ||
|
|
b5709a56b0 | ||
|
|
1b834303d0 | ||
|
|
b455a34998 | ||
|
|
d84ef6b1bb | ||
|
|
10ae5c1dd6 | ||
|
|
74967ef6ee | ||
|
|
46de23727f | ||
|
|
dc5d9f48bb | ||
|
|
3d3cdabfe7 | ||
|
|
4b1f12c0ef | ||
|
|
a9d7712fb2 | ||
|
|
a03efc1c5d | ||
|
|
9f7c353ad0 | ||
|
|
350fae4107 | ||
|
|
e09c4084b7 | ||
|
|
946722daa2 | ||
|
|
3b038d5372 | ||
|
|
6b7d387175 | ||
|
|
8a4202ea19 | ||
|
|
2eaf1112f2 | ||
|
|
d30ff07394 | ||
|
|
5616b52979 | ||
|
|
4219288701 | ||
|
|
09b221cf45 | ||
|
|
89a467cac7 | ||
|
|
c29b693038 | ||
|
|
7ab178d045 | ||
|
|
538523c58b | ||
|
|
136cf0c8bf | ||
|
|
920c858056 | ||
|
|
3d2a4bfa04 | ||
|
|
e2c9b2cb79 | ||
|
|
d38ee185bf | ||
|
|
7e10f50419 | ||
|
|
070fd96d6c | ||
|
|
156840b6ff | ||
|
|
5ff7a05ec5 | ||
|
|
c3643555ab | ||
|
|
ae2c296272 | ||
|
|
1db1c8024e | ||
|
|
2f003ca999 | ||
|
|
b1d8a21aaa | ||
|
|
cde2e66229 | ||
|
|
71d5500ed8 | ||
|
|
462321a3c8 | ||
|
|
afc0c36dd9 | ||
|
|
7f86074e07 | ||
|
|
4b9e47079c | ||
|
|
e3b9c6e143 | ||
|
|
221f184cb1 | ||
|
|
27cbbdb83f | ||
|
|
337e88560e | ||
|
|
9c96c56558 | ||
|
|
27e0c4492c | ||
|
|
80889c3cea | ||
|
|
cbbaf51e75 | ||
|
|
9778a54305 | ||
|
|
b9125537c0 | ||
|
|
fd6017a963 | ||
|
|
e0f5d6d6b3 | ||
|
|
193ab1b424 | ||
|
|
4a3b6db411 | ||
|
|
46271d0435 | ||
|
|
d4bc02c86b | ||
|
|
a12ae9a8ed | ||
|
|
6095787294 | ||
|
|
127702b96f | ||
|
|
56a6c5d9fb | ||
|
|
3b686dc3b9 | ||
|
|
c5e8dea750 | ||
|
|
29cd46eb9e | ||
|
|
92b3081896 | ||
|
|
527319ab5e | ||
|
|
8d3877c388 | ||
|
|
66b9fbd4bf |
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -74,3 +74,4 @@ test/e2e/app/dist/
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -34,6 +34,14 @@ In production, the following responsibilities are added:
|
||||
|
||||
- Minify HTML
|
||||
- Bundle multiple imports so that the browser can fetch less files
|
||||
- Generate a second version that is ES5 compatible
|
||||
- Generate a second version that is compatible with older browsers (legacy build)
|
||||
|
||||
Configuration for all these steps are specified in [bundle.js](bundle.js).
|
||||
|
||||
## Auditing browser support changes
|
||||
|
||||
`node build-scripts/list-plugins-and-polyfills.js` prints, per browserslist
|
||||
environment (modern/legacy), the Babel transforms preset-env enables and the
|
||||
Core-JS polyfills that may be injected — as collapsible markdown ready to
|
||||
paste into a PR. Use it to show the bundle impact when changing
|
||||
`.browserslistrc` or the Babel configuration.
|
||||
|
||||
@@ -121,7 +121,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
ignoreModuleNotFound: true,
|
||||
},
|
||||
],
|
||||
// Import helpers and regenerator from runtime package.
|
||||
// Import helpers from runtime package.
|
||||
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
|
||||
// corejs3 polyfill provider above otherwise redirects them to the
|
||||
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
|
||||
@@ -155,8 +155,6 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
"proxy-polyfill",
|
||||
"unfetch",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
|
||||
16: [11, 0, 0],
|
||||
17: [12, 0, 0],
|
||||
18: [13, 0, 0],
|
||||
26: [26, 0, 0],
|
||||
26: [14, 6, 0],
|
||||
};
|
||||
|
||||
const getCommonTemplateVars = () => {
|
||||
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
|
||||
...htmlMinifierOptions,
|
||||
conservativeCollapse: false,
|
||||
minifyJS: terserOptions({
|
||||
latestBuild: false, // Shared scripts should be ES5
|
||||
latestBuild: false, // Shared scripts must satisfy the legacy targets
|
||||
isTestBuild: true, // Don't need source maps
|
||||
}),
|
||||
}).then((wrapped) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
@@ -41,37 +42,6 @@ function copyMdiIcons(staticDir) {
|
||||
fs.copySync(polyPath("build/mdi"), staticPath("mdi"));
|
||||
}
|
||||
|
||||
function copyPolyfills(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
|
||||
// For custom panels using ES5 builds that don't use Babel 7+
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
|
||||
// Web Component polyfills and adapters
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js.map"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
// Lit polyfill support
|
||||
fs.copySync(
|
||||
npmPath("lit/polyfill-support.js"),
|
||||
path.join(staticPath("polyfills/"), "lit-polyfill-support.js")
|
||||
);
|
||||
|
||||
// dialog-polyfill css
|
||||
copyFileDir(
|
||||
npmPath("dialog-polyfill/dialog-polyfill.css"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
}
|
||||
|
||||
function copyFonts(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Local fonts
|
||||
@@ -89,7 +59,7 @@ function copyQrScannerWorker(staticDir) {
|
||||
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
|
||||
}
|
||||
|
||||
function copyMapPanel(staticDir) {
|
||||
async function copyMapPanel(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
copyFileDir(
|
||||
npmPath("leaflet/dist/leaflet.css"),
|
||||
@@ -103,6 +73,16 @@ 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/"));
|
||||
copyFileDir(
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
// Controls and popups of the native MapLibre engine
|
||||
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
@@ -132,14 +112,13 @@ gulp.task("copy-static-app", async () => {
|
||||
const staticDir = paths.app_output_static;
|
||||
// Basic static files
|
||||
fs.copySync(polyPath("public"), paths.app_output_root);
|
||||
copyPolyfills(staticDir);
|
||||
copyFonts(staticDir);
|
||||
copyTranslations(staticDir);
|
||||
copyLocaleData(staticDir);
|
||||
copyMdiIcons(staticDir);
|
||||
|
||||
// Panel assets
|
||||
copyMapPanel(staticDir);
|
||||
await copyMapPanel(staticDir);
|
||||
|
||||
// Qr Scanner assets
|
||||
copyZXingWasm(staticDir);
|
||||
@@ -154,8 +133,7 @@ gulp.task("copy-static-demo", async () => {
|
||||
);
|
||||
// Copy demo static files
|
||||
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
|
||||
copyPolyfills(paths.demo_output_static);
|
||||
copyMapPanel(paths.demo_output_static);
|
||||
await copyMapPanel(paths.demo_output_static);
|
||||
copyFonts(paths.demo_output_static);
|
||||
copyTranslations(paths.demo_output_static);
|
||||
copyLocaleData(paths.demo_output_static);
|
||||
@@ -167,8 +145,7 @@ gulp.task("copy-static-cast", async () => {
|
||||
fs.copySync(polyPath("public/static"), paths.cast_output_static);
|
||||
// Copy cast static files
|
||||
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
|
||||
copyPolyfills(paths.cast_output_static);
|
||||
copyMapPanel(paths.cast_output_static);
|
||||
await copyMapPanel(paths.cast_output_static);
|
||||
copyFonts(paths.cast_output_static);
|
||||
copyTranslations(paths.cast_output_static);
|
||||
copyLocaleData(paths.cast_output_static);
|
||||
@@ -184,7 +161,7 @@ gulp.task("copy-static-gallery", async () => {
|
||||
paths.gallery_output_root
|
||||
);
|
||||
|
||||
copyMapPanel(paths.gallery_output_static);
|
||||
await copyMapPanel(paths.gallery_output_static);
|
||||
copyFonts(paths.gallery_output_static);
|
||||
copyTranslations(paths.gallery_output_static);
|
||||
copyLocaleData(paths.gallery_output_static);
|
||||
@@ -214,8 +191,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
await copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
|
||||
@@ -25,7 +25,7 @@ gulp.task("gen-sensor-entity-constants", async () => {
|
||||
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
|
||||
const deviceClassUnits = data.device_class_units ?? {};
|
||||
const convertibleClassUnits = data.convertible_units ?? {};
|
||||
const stateClasses = [...(data.state_classes ?? [])].sort();
|
||||
const stateClasses = data.state_classes ?? [];
|
||||
const stateClassUnits = data.state_class_units ?? {};
|
||||
if (
|
||||
!numericDeviceClasses.length ||
|
||||
|
||||
@@ -14,6 +14,7 @@ import "./gen-icons-json.js";
|
||||
import "./gen-sensor-entity-constants.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./map-assets.js";
|
||||
import "./rspack.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Generates the MapLibre styles for the vector base map.
|
||||
//
|
||||
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
|
||||
// is what lets them be requested with an application User-Agent and without a
|
||||
// referrer. The styles stay here because they come from @versatiles/style and
|
||||
// core has no node toolchain to regenerate them with.
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { colorful, eclipse } from "@versatiles/style";
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
import { addLatinLabels } from "./map-labels.js";
|
||||
|
||||
const PROXY_PATH = "/api/map_tiles";
|
||||
const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`;
|
||||
|
||||
const outputDir = path.resolve(paths.build_dir, "map");
|
||||
|
||||
// MapLibre extends the fetched TileJSON with the style's source options, so
|
||||
// anything left here wins and freezes at build time. Dropping them is what lets
|
||||
// the proxy move the attribution and zoom range too, not just the URLs.
|
||||
const TILEJSON_FIELDS = [
|
||||
"tiles",
|
||||
"attribution",
|
||||
"bounds",
|
||||
"minzoom",
|
||||
"maxzoom",
|
||||
"scheme",
|
||||
];
|
||||
|
||||
// The builder can only write a tile URL, so the source is repointed afterwards.
|
||||
// Keyed on there being exactly one source: any other shape means the builder's
|
||||
// own default host would ship unnoticed.
|
||||
const useTileJson = (name, style) => {
|
||||
const sources = Object.values(style.sources);
|
||||
|
||||
if (sources.length !== 1) {
|
||||
throw new Error(
|
||||
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
|
||||
`point at the TileJSON. Check what @versatiles/style emits.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of TILEJSON_FIELDS) {
|
||||
delete sources[0][field];
|
||||
}
|
||||
sources[0].url = TILEJSON_URL;
|
||||
return style;
|
||||
};
|
||||
|
||||
const styleOptions = {
|
||||
// Keeps the generated URLs origin relative.
|
||||
baseUrl: "",
|
||||
glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
|
||||
sprite: [{ id: "basics", url: `${PROXY_PATH}/sprites/basics/sprites` }],
|
||||
};
|
||||
|
||||
const buildMapAssets = async () => {
|
||||
await fs.emptyDir(outputDir);
|
||||
|
||||
await Promise.all(
|
||||
// Both themes up front: dark is a real cartography, not an inverted raster.
|
||||
[
|
||||
["light", colorful],
|
||||
["dark", eclipse],
|
||||
].map(([name, builder]) =>
|
||||
writeFile(
|
||||
path.join(outputDir, `${name}.json`),
|
||||
JSON.stringify(addLatinLabels(useTileJson(name, builder(styleOptions))))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Shared so it does not have to be wired into every pipeline separately.
|
||||
let pending;
|
||||
export const ensureMapAssets = () => {
|
||||
pending ??= buildMapAssets();
|
||||
return pending;
|
||||
};
|
||||
|
||||
gulp.task("build-map-assets", ensureMapAssets);
|
||||
|
||||
export const mapAssetsDir = outputDir;
|
||||
@@ -0,0 +1,42 @@
|
||||
// Adds the English name to labels whose local name is not in Latin script.
|
||||
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
|
||||
|
||||
const NAME = ["get", "name"];
|
||||
const NAME_EN = ["get", "name_en"];
|
||||
|
||||
// Strings compare by code point: anything from Basic Latin up to Latin
|
||||
// Extended-B, digits and punctuation included.
|
||||
const IS_LATIN = ["<", NAME, "ɐ"];
|
||||
|
||||
const ENGLISH_SCALE = 0.8;
|
||||
|
||||
// Streets are line-placed and cannot break lines.
|
||||
const withEnglish = (placement) =>
|
||||
placement === "line"
|
||||
? ["concat", NAME, " (", NAME_EN, ")"]
|
||||
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
|
||||
|
||||
const isNameLabel = (layer) =>
|
||||
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
|
||||
|
||||
export const addLatinLabels = (style) => ({
|
||||
...style,
|
||||
layers: style.layers.map((layer) =>
|
||||
isNameLabel(layer)
|
||||
? {
|
||||
...layer,
|
||||
layout: {
|
||||
...layer.layout,
|
||||
"text-field": [
|
||||
"case",
|
||||
IS_LATIN,
|
||||
NAME,
|
||||
["!", ["has", "name_en"]],
|
||||
NAME,
|
||||
withEnglish(layer.layout["symbol-placement"]),
|
||||
],
|
||||
},
|
||||
}
|
||||
: layer
|
||||
),
|
||||
});
|
||||
@@ -1,38 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
|
||||
|
||||
import { version as babelVersion } from "@babel/core";
|
||||
import presetEnv from "@babel/preset-env";
|
||||
import compilationTargets from "@babel/helper-compilation-targets";
|
||||
import { transformSync } from "@babel/core";
|
||||
import compilationTargets, {
|
||||
getInclusionReasons,
|
||||
} from "@babel/helper-compilation-targets";
|
||||
import coreJSCompat from "core-js-compat";
|
||||
import { logPlugin } from "@babel/preset-env/lib/debug.js";
|
||||
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
|
||||
import { babelOptions } from "./bundle.cjs";
|
||||
|
||||
const detailsOpen = (heading) =>
|
||||
`<details>\n<summary><h4>${heading}</h4></summary>\n`;
|
||||
const detailsClose = "</details>\n";
|
||||
|
||||
const dummyAPI = {
|
||||
version: babelVersion,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
assertVersion: () => {},
|
||||
caller: (callback) =>
|
||||
callback({
|
||||
name: "Dummy Bundler",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
}),
|
||||
targets: () => ({}),
|
||||
// Copied from @babel/preset-env's internal `logPlugin`, which Babel 8 no
|
||||
// longer exposes (the package rolls up into lib/index.js and exports nothing
|
||||
// but the preset). Prints an item with the targets that require it.
|
||||
const logPlugin = (item, targetVersions, list) => {
|
||||
const filteredList = getInclusionReasons(item, targetVersions, list);
|
||||
const support = list[item];
|
||||
if (!support) {
|
||||
console.log(` ${item}`);
|
||||
return;
|
||||
}
|
||||
let formattedTargets = `{`;
|
||||
let first = true;
|
||||
for (const target of Object.keys(filteredList)) {
|
||||
if (!first) formattedTargets += `,`;
|
||||
first = false;
|
||||
formattedTargets += ` ${target}`;
|
||||
if (support[target]) formattedTargets += ` < ${support[target]}`;
|
||||
}
|
||||
formattedTargets += ` }`;
|
||||
console.log(` ${item} ${formattedTargets}`);
|
||||
};
|
||||
|
||||
// Copied from babel-plugin-polyfill-corejs3's generated
|
||||
// corejs3ShippedProposalsList, which v1 no longer exposes (it is inlined in
|
||||
// the package's rolled-up bundle).
|
||||
const shippedProposalsList = new Set([
|
||||
"esnext.array.group",
|
||||
"esnext.array.group-to-map",
|
||||
"esnext.iterator.zip",
|
||||
"esnext.iterator.zip-keyed",
|
||||
"esnext.symbol.metadata",
|
||||
]);
|
||||
|
||||
// Generate filter function based on proposal/method inputs
|
||||
// Copied and adapted from babel-plugin-polyfill-corejs3/esm/index.mjs
|
||||
const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
if (proposals || method === "entry-global") return true;
|
||||
if (shippedProposals && shippedPolyfills.default.has(name)) {
|
||||
if (shippedProposals && shippedProposalsList.has(name)) {
|
||||
return true;
|
||||
}
|
||||
if (name.startsWith("esnext.")) {
|
||||
@@ -47,7 +64,9 @@ const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
for (const buildType of ["Modern", "Legacy"]) {
|
||||
const browserslistEnv = buildType.toLowerCase();
|
||||
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
|
||||
const presetEnvOpts = babelOpts.presets[0][1];
|
||||
const presetEnvOpts = babelOpts.presets.find(
|
||||
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
|
||||
)?.[1];
|
||||
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
|
||||
// removed preset-env's `useBuiltIns`), so read its options here.
|
||||
const corejsOpts = babelOpts.plugins.find(
|
||||
@@ -55,22 +74,38 @@ for (const buildType of ["Modern", "Legacy"]) {
|
||||
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
|
||||
)?.[1];
|
||||
|
||||
// Invoking preset-env in debug mode will log the included plugins
|
||||
// Transforming an empty file with preset-env in debug mode logs the included
|
||||
// plugins. The caller declares the same capabilities babel-loader does, so
|
||||
// plugins gated on bundler support (e.g. transform-export-namespace-from)
|
||||
// match the build.
|
||||
presetEnvOpts.debug = true;
|
||||
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
|
||||
presetEnv.default(dummyAPI, {
|
||||
...presetEnvOpts,
|
||||
browserslistEnv,
|
||||
debug: true,
|
||||
transformSync("", {
|
||||
...babelOpts,
|
||||
configFile: false,
|
||||
filename: "audit.js",
|
||||
caller: {
|
||||
name: "list-plugins-and-polyfills",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
},
|
||||
});
|
||||
console.log(detailsClose);
|
||||
|
||||
// Manually log the Core-JS polyfills using the same technique
|
||||
if (corejsOpts) {
|
||||
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
|
||||
const targets = compilationTargets.default(babelOpts?.targets, {
|
||||
const targets = compilationTargets(babelOpts.targets, {
|
||||
browserslistEnv,
|
||||
});
|
||||
const polyfillList = coreJSCompat({ targets }).list.filter(
|
||||
// `version` limits the list to modules the installed core-js ships,
|
||||
// mirroring the provider's own filtering.
|
||||
const polyfillList = coreJSCompat({
|
||||
targets,
|
||||
version: corejsOpts.version,
|
||||
}).list.filter(
|
||||
polyfillFilter(
|
||||
corejsOpts.method,
|
||||
corejsOpts.proposals,
|
||||
|
||||
@@ -345,10 +345,7 @@ const createRspackConfig = ({
|
||||
"lit/directives/join$": "lit/directives/join.js",
|
||||
"lit/directives/repeat$": "lit/directives/repeat.js",
|
||||
"lit/directives/live$": "lit/directives/live.js",
|
||||
"lit/directives/keyed$": latestBuild
|
||||
? "lit/directives/keyed.js"
|
||||
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
|
||||
"lit/polyfill-support$": "lit/polyfill-support.js",
|
||||
"lit/directives/keyed$": "lit/directives/keyed.js",
|
||||
"@lit-labs/virtualizer/layouts/grid":
|
||||
"@lit-labs/virtualizer/layouts/grid.js",
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
saveTokens,
|
||||
} from "../../../../src/common/auth/token_storage";
|
||||
import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-icon";
|
||||
import "../../../../src/components/ha-list";
|
||||
@@ -197,8 +196,7 @@ class HcCast extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
toggleAttribute(
|
||||
this,
|
||||
this.toggleAttribute(
|
||||
"hide-icons",
|
||||
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { connectivityEntities } from "../stubs/connectivity/fixtures";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
@@ -53,6 +54,9 @@ export const setDemoConfig = async (
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
// Replaced the whole state map above, so the entities that do not belong to a
|
||||
// demo config have to be added back.
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Let the new registries and entities reach the dashboard before saving the
|
||||
// config, so dashboard strategies generate against them
|
||||
|
||||
+11
-1
@@ -7,6 +7,12 @@ import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import {
|
||||
connectivityCommands,
|
||||
connectivityComponents,
|
||||
connectivityEntities,
|
||||
connectivityEntityRegistryEntries,
|
||||
} from "./stubs/connectivity/fixtures";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
@@ -60,6 +66,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
...connectivityCommands,
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -87,6 +94,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
...connectivityComponents,
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -99,7 +107,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
mockLovelace(hass, localizePromise);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass);
|
||||
mockTranslations(hass, localizePromise);
|
||||
mockHistory(hass);
|
||||
mockRecorder(hass);
|
||||
mockTodo(hass);
|
||||
@@ -172,9 +180,11 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
...connectivityEntityRegistryEntries,
|
||||
]);
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Once config is loaded AND localize, set registries, entities and theme.
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mockBlueprint } from "./blueprint";
|
||||
import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockConnectivity } from "./connectivity";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
@@ -26,6 +27,7 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockCloud(hass);
|
||||
mockConfig(hass);
|
||||
mockConfigEntries(hass);
|
||||
mockConnectivity(hass);
|
||||
mockDeviceAutomation(hass);
|
||||
mockEntitySources(hass);
|
||||
mockBlueprint(hass);
|
||||
@@ -43,4 +45,10 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
hass.mockWS("llm/api/list", () => ({
|
||||
apis: [
|
||||
{ id: "assist", name: "Assist" },
|
||||
{ id: "music_assistant", name: "Music Assistant" },
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
|
||||
import type { IntegrationType } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityConfigEntries } from "./connectivity/fixtures";
|
||||
|
||||
const baseEntry = {
|
||||
source: "user",
|
||||
@@ -80,6 +81,17 @@ export const demoConfigEntries: {
|
||||
title: "Comfort level",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-mcp-server",
|
||||
domain: "mcp_server",
|
||||
title: "Assist, Music Assistant",
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
...connectivityConfigEntries,
|
||||
];
|
||||
|
||||
const filterEntries = (filters?: {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import { configEntry, device } from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const LOCAL_SOURCE = "00:1A:7D:DA:71:11";
|
||||
export const PROXY_SOURCE = "E8:DB:84:A1:C2:30";
|
||||
export const SHED_SOURCE = "A4:CF:12:9B:44:70";
|
||||
|
||||
const ADAPTER_ENTRY_ID = "mock-bluetooth";
|
||||
const PROXY_LIVING_ENTRY_ID = "mock-bluetooth-proxy-living";
|
||||
const PROXY_SHED_ENTRY_ID = "mock-bluetooth-proxy-shed";
|
||||
|
||||
export const bluetoothFixtures: ConnectivityFixtures = {
|
||||
components: ["bluetooth"],
|
||||
commands: ["bluetooth/"],
|
||||
manifests: [manifest("bluetooth", "Bluetooth", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
ADAPTER_ENTRY_ID,
|
||||
"bluetooth",
|
||||
`hci0 (${LOCAL_SOURCE})`,
|
||||
{ source: "usb", supports_options: true }
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
PROXY_LIVING_ENTRY_ID,
|
||||
"bluetooth",
|
||||
"Living room proxy",
|
||||
{ source: "esphome" }
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(PROXY_SHED_ENTRY_ID, "bluetooth", "Shed proxy", {
|
||||
source: "esphome",
|
||||
}),
|
||||
},
|
||||
],
|
||||
// Adapters and proxies are matched to their scanner by the bluetooth
|
||||
// connection tuple, see ./mock.
|
||||
devices: [
|
||||
device(
|
||||
"bluetooth-hci0",
|
||||
"hci0",
|
||||
"Home Assistant",
|
||||
"Home Assistant Green",
|
||||
ADAPTER_ENTRY_ID,
|
||||
{ connections: [["bluetooth", LOCAL_SOURCE]] }
|
||||
),
|
||||
device(
|
||||
"bluetooth-proxy-living",
|
||||
"Living room proxy",
|
||||
"Espressif",
|
||||
"ESP32-C3",
|
||||
PROXY_LIVING_ENTRY_ID,
|
||||
{ area_id: "living_room", connections: [["bluetooth", PROXY_SOURCE]] }
|
||||
),
|
||||
device(
|
||||
"bluetooth-proxy-shed",
|
||||
"Shed proxy",
|
||||
"Espressif",
|
||||
"ESP32",
|
||||
PROXY_SHED_ENTRY_ID,
|
||||
{ connections: [["bluetooth", SHED_SOURCE]] }
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
BluetoothAllocationsData,
|
||||
BluetoothDeviceData,
|
||||
BluetoothScannerDetails,
|
||||
BluetoothScannerState,
|
||||
} from "../../../../../src/data/bluetooth";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
import { LOCAL_SOURCE, PROXY_SOURCE, SHED_SOURCE } from "./fixtures";
|
||||
|
||||
const SCANNERS: BluetoothScannerDetails[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
connectable: true,
|
||||
name: "hci0 (Home Assistant Green)",
|
||||
adapter: "hci0",
|
||||
scanner_type: "usb",
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
connectable: true,
|
||||
name: "Living room proxy",
|
||||
adapter: "esp32",
|
||||
scanner_type: "remote",
|
||||
},
|
||||
{
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
name: "Shed proxy",
|
||||
adapter: "esp32",
|
||||
scanner_type: "remote",
|
||||
},
|
||||
];
|
||||
|
||||
const SCANNER_STATES: BluetoothScannerState[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
adapter: "hci0",
|
||||
current_mode: "active",
|
||||
requested_mode: "active",
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
adapter: "esp32",
|
||||
current_mode: "active",
|
||||
requested_mode: "active",
|
||||
},
|
||||
{
|
||||
// A proxy configured for passive scanning: it reports advertisements but
|
||||
// cannot connect, matching its `connectable: false` scanner details.
|
||||
source: SHED_SOURCE,
|
||||
adapter: "esp32",
|
||||
current_mode: "passive",
|
||||
requested_mode: "passive",
|
||||
},
|
||||
];
|
||||
|
||||
const ALLOCATIONS: BluetoothAllocationsData[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
slots: 5,
|
||||
free: 3,
|
||||
allocated: ["A4:C1:38:11:22:33", "E7:2E:00:B1:9A:1C"],
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
slots: 3,
|
||||
free: 2,
|
||||
allocated: ["FC:58:FA:12:34:56"],
|
||||
},
|
||||
];
|
||||
|
||||
interface DemoAdvertisement {
|
||||
address: string;
|
||||
name: string;
|
||||
rssi: number;
|
||||
source: string;
|
||||
connectable?: boolean;
|
||||
tx_power?: number;
|
||||
manufacturer_data?: Record<number, string>;
|
||||
service_data?: Record<string, string>;
|
||||
service_uuids?: string[];
|
||||
}
|
||||
|
||||
const ADVERTISEMENTS: DemoAdvertisement[] = [
|
||||
{
|
||||
address: "A4:C1:38:11:22:33",
|
||||
name: "Govee H5075",
|
||||
rssi: -58,
|
||||
source: LOCAL_SOURCE,
|
||||
manufacturer_data: { 60552: "000104a10b64" },
|
||||
service_uuids: ["0000ec88-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "E7:2E:00:B1:9A:1C",
|
||||
name: "SwitchBot Meter",
|
||||
rssi: -71,
|
||||
source: LOCAL_SOURCE,
|
||||
service_data: { "0000fd3d-0000-1000-8000-00805f9b34fb": "5400648c14" },
|
||||
service_uuids: ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
|
||||
},
|
||||
{
|
||||
address: "FC:58:FA:12:34:56",
|
||||
name: "Xiaomi LYWSD03MMC",
|
||||
rssi: -64,
|
||||
source: PROXY_SOURCE,
|
||||
service_data: { "0000fe95-0000-1000-8000-00805f9b34fb": "3058590e" },
|
||||
},
|
||||
{
|
||||
address: "C4:7C:8D:6A:5B:20",
|
||||
name: "Flower care",
|
||||
rssi: -88,
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
service_uuids: ["0000fe95-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "D0:36:9A:7F:11:80",
|
||||
name: "Tile Mate",
|
||||
rssi: -79,
|
||||
source: PROXY_SOURCE,
|
||||
connectable: false,
|
||||
service_uuids: ["0000feed-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "5C:C7:C1:04:9E:2A",
|
||||
name: "Nut Find 3",
|
||||
rssi: -93,
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const buildAdvertisement = (
|
||||
advertisement: DemoAdvertisement
|
||||
): BluetoothDeviceData => ({
|
||||
address: advertisement.address,
|
||||
name: advertisement.name,
|
||||
rssi: advertisement.rssi,
|
||||
source: advertisement.source,
|
||||
connectable: advertisement.connectable ?? true,
|
||||
manufacturer_data: advertisement.manufacturer_data ?? {},
|
||||
service_data: advertisement.service_data ?? {},
|
||||
service_uuids: advertisement.service_uuids ?? [],
|
||||
tx_power: advertisement.tx_power ?? -59,
|
||||
time: Date.now() / 1000,
|
||||
raw: null,
|
||||
});
|
||||
|
||||
// Nudge the signal strength a little on every tick so the monitors and the
|
||||
// network map look alive without the rows jumping around.
|
||||
const jitter = (rssi: number) =>
|
||||
Math.max(-99, Math.min(-30, rssi + Math.round(Math.random() * 4) - 2));
|
||||
|
||||
export const mockBluetooth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("bluetooth/subscribe_scanner_details", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.({ add: SCANNERS }))
|
||||
);
|
||||
|
||||
hass.mockWS("bluetooth/subscribe_scanner_state", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => SCANNER_STATES.forEach((state) => onChange?.(state)))
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"bluetooth/subscribe_connection_allocations",
|
||||
(msg: { config_entry_id?: string }, _hass, onChange) =>
|
||||
emitInitial(() =>
|
||||
onChange?.(
|
||||
msg.config_entry_id
|
||||
? ALLOCATIONS.filter((a) => a.source === LOCAL_SOURCE)
|
||||
: ALLOCATIONS
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
hass.mockWS("bluetooth/subscribe_advertisements", (_msg, _hass, onChange) => {
|
||||
let advertisements = ADVERTISEMENTS;
|
||||
const stopInitial = emitInitial(() =>
|
||||
onChange?.({ add: advertisements.map(buildAdvertisement) })
|
||||
);
|
||||
const interval = window.setInterval(() => {
|
||||
advertisements = advertisements.map((advertisement) => ({
|
||||
...advertisement,
|
||||
rssi: jitter(advertisement.rssi),
|
||||
}));
|
||||
onChange?.({ change: advertisements.map(buildAdvertisement) });
|
||||
}, 5000);
|
||||
return () => {
|
||||
stopInitial();
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { bluetoothFixtures } from "./bluetooth/fixtures";
|
||||
import { infraredFixtures } from "./infrared/fixtures";
|
||||
import { matterFixtures } from "./matter/fixtures";
|
||||
import { mqttFixtures } from "./mqtt/fixtures";
|
||||
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
|
||||
import { serialFixtures } from "./serial/fixtures";
|
||||
import { tagsFixtures } from "./tags/fixtures";
|
||||
import { threadFixtures } from "./thread/fixtures";
|
||||
import type { ConnectivityFixtures } from "./types";
|
||||
import { zhaFixtures } from "./zha/fixtures";
|
||||
import { zwaveJsFixtures } from "./zwave_js/fixtures";
|
||||
|
||||
// Every integration reachable from Settings > Connectivity that has frontend
|
||||
// data to mock. Each owns its own fixtures, so they can be added and removed
|
||||
// one at a time.
|
||||
const INTEGRATIONS: ConnectivityFixtures[] = [
|
||||
bluetoothFixtures,
|
||||
serialFixtures,
|
||||
mqttFixtures,
|
||||
matterFixtures,
|
||||
infraredFixtures,
|
||||
zwaveJsFixtures,
|
||||
zhaFixtures,
|
||||
radioFrequencyFixtures,
|
||||
tagsFixtures,
|
||||
threadFixtures,
|
||||
];
|
||||
|
||||
const collect = <T>(
|
||||
pick: (fixtures: ConnectivityFixtures) => T[] | undefined
|
||||
) => INTEGRATIONS.flatMap((fixtures) => pick(fixtures) ?? []);
|
||||
|
||||
export const connectivityComponents = collect((f) => f.components);
|
||||
|
||||
export const connectivityCommands = collect((f) => f.commands);
|
||||
|
||||
export const connectivityConfigEntries = collect((f) => f.configEntries);
|
||||
|
||||
export const connectivityManifests = collect((f) => f.manifests);
|
||||
|
||||
export const connectivityDevices = collect((f) => f.devices);
|
||||
|
||||
export const connectivityEntityRegistryEntries = collect(
|
||||
(f) => f.entityRegistryEntries
|
||||
);
|
||||
|
||||
export const connectivityEntities = () =>
|
||||
INTEGRATIONS.flatMap((fixtures) => fixtures.entities?.() ?? []);
|
||||
|
||||
/** Backend translation resources, merged per category. */
|
||||
export const connectivityBackendTranslations = INTEGRATIONS.reduce<
|
||||
Record<string, Record<string, string>>
|
||||
>((resources, fixtures) => {
|
||||
for (const [category, keys] of Object.entries(
|
||||
fixtures.backendTranslations ?? {}
|
||||
)) {
|
||||
resources[category] = { ...resources[category], ...keys };
|
||||
}
|
||||
return resources;
|
||||
}, {});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ConfigEntry } from "../../../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type { EntityInput } from "../../../../src/fake_data/entities/types";
|
||||
|
||||
// Builders for the registry fixtures, so each integration only spells out what
|
||||
// makes its own entries different.
|
||||
|
||||
const BASE_CONFIG_ENTRY = {
|
||||
source: "user",
|
||||
state: "loaded" as const,
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
|
||||
export const configEntry = (
|
||||
entryId: string,
|
||||
domain: string,
|
||||
title: string,
|
||||
extra: Partial<ConfigEntry> = {}
|
||||
): ConfigEntry => ({
|
||||
...BASE_CONFIG_ENTRY,
|
||||
entry_id: entryId,
|
||||
domain,
|
||||
title,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const BASE_DEVICE = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
entry_type: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const device = (
|
||||
id: string,
|
||||
name: string,
|
||||
manufacturer: string,
|
||||
model: string,
|
||||
entryId: string,
|
||||
extra: Partial<DeviceRegistryEntry> = {}
|
||||
): DeviceRegistryEntry => ({
|
||||
...BASE_DEVICE,
|
||||
id,
|
||||
name,
|
||||
manufacturer,
|
||||
model,
|
||||
config_entries: [entryId],
|
||||
primary_config_entry: entryId,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const BASE_REGISTRY_ENTRY = {
|
||||
config_subentry_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
icon: null,
|
||||
labels: [] as string[],
|
||||
categories: {},
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
options: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const registryEntry = (
|
||||
entityId: string,
|
||||
deviceId: string,
|
||||
entryId: string,
|
||||
platform: string,
|
||||
name?: string
|
||||
): EntityRegistryEntry => ({
|
||||
...BASE_REGISTRY_ENTRY,
|
||||
entity_id: entityId,
|
||||
id: entityId,
|
||||
unique_id: entityId,
|
||||
device_id: deviceId,
|
||||
config_entry_id: entryId,
|
||||
platform,
|
||||
name: name ?? null,
|
||||
has_entity_name: name === undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* The demo's `addEntities` builds the display entity registry from the entity
|
||||
* inputs, so mirror each state's registry entry onto it. Panels count entities
|
||||
* per device and per integration.
|
||||
*/
|
||||
export const withRegistryLinks = (
|
||||
entries: EntityRegistryEntry[],
|
||||
states: Record<string, EntityInput>
|
||||
): EntityInput[] =>
|
||||
Object.values(states).map((state) => {
|
||||
const entry = entries.find(
|
||||
(candidate) => candidate.entity_id === state.entity_id
|
||||
);
|
||||
return entry
|
||||
? { ...state, device_id: entry.device_id!, platform: entry.platform }
|
||||
: state;
|
||||
});
|
||||
|
||||
export const minutesAgo = (minutes: number) =>
|
||||
new Date(Date.now() - minutes * 60000).toISOString();
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { mockBluetooth } from "./bluetooth/mock";
|
||||
import { mockMatter } from "./matter/mock";
|
||||
import { mockMqtt } from "./mqtt/mock";
|
||||
import { mockRadioFrequency } from "./radio_frequency/mock";
|
||||
import { mockSerial } from "./serial/mock";
|
||||
import { mockThread } from "./thread/mock";
|
||||
import { mockZha } from "./zha/mock";
|
||||
import { mockZwaveJs } from "./zwave_js/mock";
|
||||
|
||||
// The WebSocket mocks, code-split into the config panel chunk.
|
||||
const MOCKS = [
|
||||
mockBluetooth,
|
||||
mockSerial,
|
||||
mockMatter,
|
||||
mockMqtt,
|
||||
mockZwaveJs,
|
||||
mockZha,
|
||||
mockRadioFrequency,
|
||||
mockThread,
|
||||
];
|
||||
|
||||
export const mockConnectivity = (hass: MockHomeAssistant) => {
|
||||
MOCKS.forEach((mock) => mock(hass));
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
minutesAgo,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-broadlink";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"broadlink-living-room",
|
||||
"Living room blaster",
|
||||
"Broadlink",
|
||||
"RM4 pro",
|
||||
ENTRY_ID,
|
||||
{ area_id: "living_room" }
|
||||
),
|
||||
device(
|
||||
"broadlink-bedroom",
|
||||
"Bedroom blaster",
|
||||
"Broadlink",
|
||||
"RM mini 3",
|
||||
ENTRY_ID,
|
||||
{ area_id: "bedroom" }
|
||||
),
|
||||
];
|
||||
|
||||
// The infrared panel is entity driven: the proxy entities live in the
|
||||
// `infrared` domain while their registry platform stays the integration that
|
||||
// provides them.
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"infrared.living_room_blaster_emitter",
|
||||
"broadlink-living-room",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Emitter"
|
||||
),
|
||||
registryEntry(
|
||||
"infrared.living_room_blaster_receiver",
|
||||
"broadlink-living-room",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Receiver"
|
||||
),
|
||||
registryEntry(
|
||||
"infrared.bedroom_blaster_emitter",
|
||||
"broadlink-bedroom",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Emitter"
|
||||
),
|
||||
];
|
||||
|
||||
export const infraredFixtures: ConnectivityFixtures = {
|
||||
components: ["infrared"],
|
||||
manifests: [
|
||||
manifest("broadlink", "Broadlink", {
|
||||
integration_type: "hub",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{ type: "hub", entry: configEntry(ENTRY_ID, "broadlink", "RM4 pro") },
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
// A proxy entity's state is the timestamp it was last used.
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"infrared.living_room_blaster_emitter": {
|
||||
entity_id: "infrared.living_room_blaster_emitter",
|
||||
state: minutesAgo(12),
|
||||
attributes: {
|
||||
friendly_name: "Living room blaster Emitter",
|
||||
device_class: "emitter",
|
||||
},
|
||||
},
|
||||
"infrared.living_room_blaster_receiver": {
|
||||
entity_id: "infrared.living_room_blaster_receiver",
|
||||
state: minutesAgo(3),
|
||||
attributes: {
|
||||
friendly_name: "Living room blaster Receiver",
|
||||
device_class: "receiver",
|
||||
},
|
||||
},
|
||||
"infrared.bedroom_blaster_emitter": {
|
||||
entity_id: "infrared.bedroom_blaster_emitter",
|
||||
state: minutesAgo(1440),
|
||||
attributes: {
|
||||
friendly_name: "Bedroom blaster Emitter",
|
||||
device_class: "emitter",
|
||||
},
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
entity_component: {
|
||||
// The emitter is the default device class, stored under the "_" key.
|
||||
"component.infrared.entity_component._.name": "Emitter",
|
||||
"component.infrared.entity_component.receiver.name": "Receiver",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-matter";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"matter-kitchen-light",
|
||||
"Kitchen ceiling",
|
||||
"Nanoleaf",
|
||||
"Essentials A19",
|
||||
ENTRY_ID,
|
||||
{ area_id: "kitchen", sw_version: "3.5.7" }
|
||||
),
|
||||
device(
|
||||
"matter-side-door-lock",
|
||||
"Side door lock",
|
||||
"Aqara",
|
||||
"Smart Lock U100",
|
||||
ENTRY_ID,
|
||||
{ sw_version: "1.2.0" }
|
||||
),
|
||||
device("matter-office-plug", "Office plug", "Eve", "Energy", ENTRY_ID, {
|
||||
area_id: "office",
|
||||
sw_version: "3.2.0",
|
||||
}),
|
||||
device("matter-patio-sensor", "Patio sensor", "Eve", "Weather", ENTRY_ID, {
|
||||
area_id: "garden",
|
||||
sw_version: "3.2.1",
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"light.kitchen_ceiling",
|
||||
"matter-kitchen-light",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
registryEntry("lock.side_door", "matter-side-door-lock", ENTRY_ID, "matter"),
|
||||
registryEntry("switch.office_plug", "matter-office-plug", ENTRY_ID, "matter"),
|
||||
registryEntry(
|
||||
"sensor.office_plug_power",
|
||||
"matter-office-plug",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.patio_temperature",
|
||||
"matter-patio-sensor",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
];
|
||||
|
||||
export const matterFixtures: ConnectivityFixtures = {
|
||||
components: ["matter"],
|
||||
commands: ["matter/"],
|
||||
manifests: [manifest("matter", "Matter", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "matter", "Matter", {
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.kitchen_ceiling": {
|
||||
entity_id: "light.kitchen_ceiling",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen ceiling",
|
||||
supported_color_modes: ["color_temp"],
|
||||
color_mode: "color_temp",
|
||||
brightness: 204,
|
||||
},
|
||||
},
|
||||
"lock.side_door": {
|
||||
entity_id: "lock.side_door",
|
||||
state: "locked",
|
||||
attributes: { friendly_name: "Side door lock" },
|
||||
},
|
||||
"switch.office_plug": {
|
||||
entity_id: "switch.office_plug",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Office plug" },
|
||||
},
|
||||
"sensor.office_plug_power": {
|
||||
entity_id: "sensor.office_plug_power",
|
||||
state: "42.5",
|
||||
attributes: {
|
||||
friendly_name: "Office plug power",
|
||||
device_class: "power",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "W",
|
||||
},
|
||||
},
|
||||
"sensor.patio_temperature": {
|
||||
entity_id: "sensor.patio_temperature",
|
||||
state: "14.2",
|
||||
attributes: {
|
||||
friendly_name: "Patio temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
import type {
|
||||
MatterCommissioningParameters,
|
||||
MatterFabricData,
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterNodeDiagnostics,
|
||||
} from "../../../../../src/data/matter";
|
||||
import { NetworkType, NodeType } from "../../../../../src/data/matter";
|
||||
import type {
|
||||
MatterLockInfo,
|
||||
MatterLockUser,
|
||||
MatterLockUsersResponse,
|
||||
SetMatterLockCredentialResult,
|
||||
} from "../../../../../src/data/matter-lock";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const EXT_PAN_ID = "dead00beef00cafe";
|
||||
const THREAD_NETWORK = "ha-thread";
|
||||
|
||||
const NODES: MatterNetworkTopologyNode[] = [
|
||||
{
|
||||
id: "otbr",
|
||||
kind: "border_router",
|
||||
network_type: "thread",
|
||||
ha_device_id: null,
|
||||
role: "leader",
|
||||
available: true,
|
||||
ext_address: "f6a1c30d2b4e5f61",
|
||||
rloc16: 0x4000,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
host_name: "homeassistant",
|
||||
vendor_name: "Home Assistant",
|
||||
model_name: "OpenThread Border Router",
|
||||
},
|
||||
{
|
||||
id: "wifi-ap",
|
||||
kind: "wifi_ap",
|
||||
network_type: "wifi",
|
||||
ha_device_id: null,
|
||||
available: true,
|
||||
ssid: "Home",
|
||||
bssid: "3c:37:86:11:22:33",
|
||||
vendor_name: "Ubiquiti",
|
||||
model_name: "U6 Pro",
|
||||
},
|
||||
{
|
||||
id: "node-1",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 1,
|
||||
ha_device_id: "matter-kitchen-light",
|
||||
available: true,
|
||||
role: "router",
|
||||
ext_address: "10a2b3c4d5e6f708",
|
||||
rloc16: 0x8401,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Nanoleaf",
|
||||
model_name: "Essentials A19",
|
||||
},
|
||||
{
|
||||
id: "node-2",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 2,
|
||||
ha_device_id: "matter-side-door-lock",
|
||||
available: true,
|
||||
role: "sleepy_end_device",
|
||||
ext_address: "20b3c4d5e6f70819",
|
||||
rloc16: 0x8402,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Aqara",
|
||||
model_name: "Smart Lock U100",
|
||||
},
|
||||
{
|
||||
id: "node-3",
|
||||
kind: "matter",
|
||||
network_type: "wifi",
|
||||
node_id: 3,
|
||||
ha_device_id: "matter-office-plug",
|
||||
available: true,
|
||||
ssid: "Home",
|
||||
vendor_name: "Eve",
|
||||
model_name: "Energy",
|
||||
},
|
||||
{
|
||||
id: "node-4",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 4,
|
||||
ha_device_id: "matter-patio-sensor",
|
||||
available: false,
|
||||
role: "end_device",
|
||||
ext_address: "30c4d5e6f708192a",
|
||||
rloc16: 0x8403,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Eve",
|
||||
model_name: "Weather",
|
||||
},
|
||||
{
|
||||
id: "thread-unknown-1",
|
||||
kind: "thread_unknown",
|
||||
network_type: "thread",
|
||||
available: true,
|
||||
role: "end_device",
|
||||
ext_address: "40d5e6f708192a3b",
|
||||
rloc16: 0x8404,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
},
|
||||
];
|
||||
|
||||
const connection = (
|
||||
source: string,
|
||||
target: string,
|
||||
network: string,
|
||||
strength: MatterNetworkTopologyConnection["strength"],
|
||||
lqi?: number,
|
||||
rssi?: number
|
||||
): MatterNetworkTopologyConnection => ({
|
||||
source,
|
||||
target,
|
||||
network,
|
||||
strength,
|
||||
source_to_target: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
|
||||
target_to_source: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
|
||||
via_route_table: false,
|
||||
path_cost: null,
|
||||
});
|
||||
|
||||
const CONNECTIONS: MatterNetworkTopologyConnection[] = [
|
||||
connection("otbr", "node-1", "thread", "strong", 245, -42),
|
||||
connection("otbr", "node-2", "thread", "medium", 160, -68),
|
||||
connection("node-1", "node-4", "thread", "weak", 84, -86),
|
||||
connection("node-1", "thread-unknown-1", "thread", "medium", 172, -63),
|
||||
connection("wifi-ap", "node-3", "wifi", "strong", undefined, -47),
|
||||
];
|
||||
|
||||
const TOPOLOGY: MatterNetworkTopology = {
|
||||
collected_at: Date.now() / 1000,
|
||||
nodes: NODES,
|
||||
connections: CONNECTIONS,
|
||||
};
|
||||
|
||||
const buildTopology = (): MatterNetworkTopology => ({
|
||||
...TOPOLOGY,
|
||||
collected_at: Date.now() / 1000,
|
||||
});
|
||||
|
||||
const FABRICS: MatterFabricData[] = [
|
||||
{
|
||||
fabric_id: 1,
|
||||
vendor_id: 4939,
|
||||
fabric_index: 1,
|
||||
fabric_label: "Home Assistant",
|
||||
vendor_name: "Home Assistant",
|
||||
},
|
||||
];
|
||||
|
||||
const NODE_TYPE_BY_ROLE: Record<string, NodeType> = {
|
||||
router: NodeType.ROUTING_END_DEVICE,
|
||||
sleepy_end_device: NodeType.SLEEPY_END_DEVICE,
|
||||
end_device: NodeType.END_DEVICE,
|
||||
};
|
||||
|
||||
const NODES_BY_DEVICE_ID = new Map(
|
||||
NODES.filter((node) => node.ha_device_id).map((node) => [
|
||||
node.ha_device_id!,
|
||||
node,
|
||||
])
|
||||
);
|
||||
|
||||
const nodeIpAddress = (node: MatterNetworkTopologyNode): string =>
|
||||
node.network_type === "thread"
|
||||
? `fd11:2233:4455:6677::${(node.node_id ?? 0).toString(16)}`
|
||||
: `192.168.1.${100 + (node.node_id ?? 0)}`;
|
||||
|
||||
// Diagnostics are derived from the topology so a device's transport,
|
||||
// availability and node type match the map. The device page reads them per
|
||||
// device, and gates its actions on `available` and `network_type`.
|
||||
const buildNodeDiagnostics = (
|
||||
node: MatterNetworkTopologyNode
|
||||
): MatterNodeDiagnostics => ({
|
||||
node_id: node.node_id!,
|
||||
network_type:
|
||||
node.network_type === "thread" ? NetworkType.THREAD : NetworkType.WIFI,
|
||||
node_type: node.is_bridge
|
||||
? NodeType.BRIDGE
|
||||
: (NODE_TYPE_BY_ROLE[node.role ?? ""] ?? NodeType.END_DEVICE),
|
||||
network_name: node.network_name ?? node.ssid ?? undefined,
|
||||
ip_adresses: [nodeIpAddress(node)],
|
||||
mac_address: node.ext_address?.match(/.{2}/g)?.join(":"),
|
||||
available: node.available !== false,
|
||||
active_fabrics: FABRICS,
|
||||
active_fabric_index: 1,
|
||||
});
|
||||
|
||||
// The backend resolves the device before acting, so both node commands answer
|
||||
// the same way when it cannot.
|
||||
const nodeNotFound = (deviceId: string) =>
|
||||
Promise.reject({
|
||||
code: "node_not_found",
|
||||
message: `No Matter node for device ${deviceId}`,
|
||||
});
|
||||
|
||||
// The manual and QR codes below are the Matter test payload for passcode
|
||||
// 20202021 with discriminator 3840; the three have to agree.
|
||||
const COMMISSIONING_PARAMETERS: MatterCommissioningParameters = {
|
||||
setup_pin_code: 20202021,
|
||||
setup_manual_code: "34970112332",
|
||||
setup_qr_code: "MT:Y.K9042C00KA0648G00",
|
||||
};
|
||||
|
||||
const LOCK_INFO: MatterLockInfo = {
|
||||
supports_user_management: true,
|
||||
supported_credential_types: ["pin"],
|
||||
max_users: 10,
|
||||
max_pin_users: 10,
|
||||
max_rfid_users: null,
|
||||
max_credentials_per_user: 2,
|
||||
min_pin_length: 4,
|
||||
max_pin_length: 8,
|
||||
min_rfid_length: null,
|
||||
max_rfid_length: null,
|
||||
};
|
||||
|
||||
const initialLockUsers = (): MatterLockUser[] => [
|
||||
{
|
||||
user_index: 1,
|
||||
user_name: "Anne",
|
||||
user_unique_id: 1,
|
||||
user_status: "occupied_enabled",
|
||||
user_type: "unrestricted_user",
|
||||
credential_rule: "single",
|
||||
credentials: [{ type: "pin", index: 1 }],
|
||||
next_user_index: 2,
|
||||
},
|
||||
{
|
||||
user_index: 2,
|
||||
user_name: "Cleaner",
|
||||
user_unique_id: 2,
|
||||
user_status: "occupied_disabled",
|
||||
user_type: "week_day_schedule_user",
|
||||
credential_rule: "single",
|
||||
credentials: [{ type: "pin", index: 2 }],
|
||||
next_user_index: null,
|
||||
},
|
||||
];
|
||||
|
||||
// The manage dialog reloads the list after every add, edit and delete, so the
|
||||
// mocked services keep the lock's users rather than answering from a constant,
|
||||
// which would make every change look like it was reverted.
|
||||
const lockUsers = new Map<string, MatterLockUser[]>();
|
||||
|
||||
const usersFor = (entityId: string): MatterLockUser[] => {
|
||||
let users = lockUsers.get(entityId);
|
||||
if (!users) {
|
||||
users = initialLockUsers();
|
||||
lockUsers.set(entityId, users);
|
||||
}
|
||||
return users;
|
||||
};
|
||||
|
||||
// Lowest free slot, the way a lock hands out user and credential indexes.
|
||||
const nextFreeIndex = (taken: number[]): number => {
|
||||
let index = 1;
|
||||
while (taken.includes(index)) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
export const mockMatter = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("matter/network_topology", () => buildTopology());
|
||||
|
||||
hass.mockWS("matter/subscribe_network_topology", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.(buildTopology()))
|
||||
);
|
||||
|
||||
hass.mockWS("matter/node_diagnostics", (msg: { device_id: string }) => {
|
||||
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
|
||||
return node ? buildNodeDiagnostics(node) : nodeNotFound(msg.device_id);
|
||||
});
|
||||
|
||||
hass.mockWS("matter/ping_node", (msg: { device_id: string }) => {
|
||||
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
|
||||
return node
|
||||
? { [nodeIpAddress(node)]: node.available !== false }
|
||||
: nodeNotFound(msg.device_id);
|
||||
});
|
||||
|
||||
hass.mockWS("matter/interview_node", () => undefined);
|
||||
|
||||
// Actions the device page offers for an available node. Without these the
|
||||
// dialogs behind them fail with `command_not_mocked`.
|
||||
hass.mockWS(
|
||||
"matter/open_commissioning_window",
|
||||
() => COMMISSIONING_PARAMETERS
|
||||
);
|
||||
hass.mockWS("matter/remove_matter_fabric", () => undefined);
|
||||
hass.mockWS("matter/set_wifi_credentials", () => undefined);
|
||||
hass.mockWS("matter/set_thread", () => undefined);
|
||||
|
||||
// The lock device exposes "Manage lock", whose dialog reads back the response
|
||||
// of these services.
|
||||
hass.mockService("matter", "get_lock_info", (_data, target) => ({
|
||||
[target!.entity_id]: LOCK_INFO,
|
||||
}));
|
||||
hass.mockService("matter", "get_lock_users", (_data, target) => ({
|
||||
// Copied, the way a real response would be: the dialog assigns the list to
|
||||
// reactive state, so handing back the same array leaves it unchanged and
|
||||
// the list never rerenders.
|
||||
[target!.entity_id]: {
|
||||
max_users: LOCK_INFO.max_users!,
|
||||
users: usersFor(target!.entity_id).map((user) => ({
|
||||
...user,
|
||||
credentials: user.credentials.map((credential) => ({ ...credential })),
|
||||
})),
|
||||
} satisfies MatterLockUsersResponse,
|
||||
}));
|
||||
|
||||
// Renames the user the credential below created, or edits an existing one.
|
||||
hass.mockService("matter", "set_lock_user", (data, target) => {
|
||||
const user = usersFor(target!.entity_id).find(
|
||||
(candidate) => candidate.user_index === data?.user_index
|
||||
);
|
||||
if (user) {
|
||||
if (data?.user_name !== undefined) {
|
||||
user.user_name = data.user_name;
|
||||
}
|
||||
if (data?.user_type !== undefined) {
|
||||
user.user_type = data.user_type;
|
||||
}
|
||||
if (data?.credential_rule !== undefined) {
|
||||
user.credential_rule = data.credential_rule;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
hass.mockService("matter", "clear_lock_user", (data, target) => {
|
||||
const users = usersFor(target!.entity_id);
|
||||
const index = users.findIndex(
|
||||
(candidate) => candidate.user_index === data?.user_index
|
||||
);
|
||||
if (index !== -1) {
|
||||
users.splice(index, 1);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// Adding a user starts here: the credential creates it, and the dialog reads
|
||||
// the assigned index straight back to name it.
|
||||
hass.mockService("matter", "set_lock_credential", (data, target) => {
|
||||
const users = usersFor(target!.entity_id);
|
||||
const userIndex =
|
||||
(data?.user_index as number | null | undefined) ??
|
||||
nextFreeIndex(
|
||||
users
|
||||
.map((user) => user.user_index)
|
||||
.filter((i): i is number => i !== null)
|
||||
);
|
||||
const credentialIndex =
|
||||
(data?.credential_index as number | null | undefined) ??
|
||||
nextFreeIndex(
|
||||
users.flatMap((user) =>
|
||||
user.credentials
|
||||
.map((credential) => credential.index)
|
||||
.filter((i): i is number => i !== null)
|
||||
)
|
||||
);
|
||||
const credential = {
|
||||
type: (data?.credential_type as string) ?? "pin",
|
||||
index: credentialIndex,
|
||||
};
|
||||
const user = users.find((candidate) => candidate.user_index === userIndex);
|
||||
if (user) {
|
||||
user.credentials = [...user.credentials, credential];
|
||||
} else {
|
||||
users.push({
|
||||
user_index: userIndex,
|
||||
user_name: null,
|
||||
user_unique_id: userIndex,
|
||||
user_status: data?.user_status ?? "occupied_enabled",
|
||||
user_type: data?.user_type ?? "unrestricted_user",
|
||||
credential_rule: "single",
|
||||
credentials: [credential],
|
||||
next_user_index: null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
[target!.entity_id]: {
|
||||
credential_index: credentialIndex,
|
||||
user_index: userIndex,
|
||||
next_credential_index: null,
|
||||
} satisfies SetMatterLockCredentialResult,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-mqtt";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"mqtt-fridge-sensor",
|
||||
"Fridge sensor",
|
||||
"Xiaomi",
|
||||
"LYWSD03MMC",
|
||||
ENTRY_ID,
|
||||
{ area_id: "kitchen" }
|
||||
),
|
||||
device(
|
||||
"mqtt-garage-door",
|
||||
"Garage door",
|
||||
"Shelly",
|
||||
"Shelly Plus 1",
|
||||
ENTRY_ID
|
||||
),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"sensor.fridge_temperature",
|
||||
"mqtt-fridge-sensor",
|
||||
ENTRY_ID,
|
||||
"mqtt"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.fridge_battery",
|
||||
"mqtt-fridge-sensor",
|
||||
ENTRY_ID,
|
||||
"mqtt"
|
||||
),
|
||||
registryEntry("cover.garage_door", "mqtt-garage-door", ENTRY_ID, "mqtt"),
|
||||
];
|
||||
|
||||
export const mqttFixtures: ConnectivityFixtures = {
|
||||
components: ["mqtt"],
|
||||
// `execute_script` too: the panel publishes through a script action.
|
||||
commands: ["mqtt/", "execute_script"],
|
||||
manifests: [manifest("mqtt", "MQTT", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "mqtt", "core-mosquitto", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"sensor.fridge_temperature": {
|
||||
entity_id: "sensor.fridge_temperature",
|
||||
state: "21.4",
|
||||
attributes: {
|
||||
friendly_name: "Fridge temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"sensor.fridge_battery": {
|
||||
entity_id: "sensor.fridge_battery",
|
||||
state: "92",
|
||||
attributes: {
|
||||
friendly_name: "Fridge battery",
|
||||
device_class: "battery",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"cover.garage_door": {
|
||||
entity_id: "cover.garage_door",
|
||||
state: "closed",
|
||||
attributes: { friendly_name: "Garage door", device_class: "garage" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
MQTTDeviceDebugInfo,
|
||||
MQTTMessage,
|
||||
} from "../../../../../src/data/mqtt";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const PAYLOADS: Record<string, () => string> = {
|
||||
"homeassistant/status": () => "online",
|
||||
"zigbee2mqtt/bridge/state": () => '{"state":"online"}',
|
||||
default: () =>
|
||||
JSON.stringify({
|
||||
battery: 92,
|
||||
linkquality: 120,
|
||||
// Built from whole tenths, so the payload never carries the noise a
|
||||
// float sum leaves behind, like 21.599999999999998.
|
||||
temperature: (214 + Math.floor(Math.random() * 11)) / 10,
|
||||
}),
|
||||
};
|
||||
|
||||
// Subscriptions take a topic filter, but a message carries the topic it was
|
||||
// actually published on, so a filter has to be resolved to one concrete topic
|
||||
// before it can be echoed back.
|
||||
const resolveFilter = (filter: string): string =>
|
||||
filter
|
||||
.split("/")
|
||||
.flatMap((level) => {
|
||||
if (level === "+") {
|
||||
return ["kitchen"];
|
||||
}
|
||||
if (level === "#") {
|
||||
return ["kitchen", "temperature"];
|
||||
}
|
||||
return [level];
|
||||
})
|
||||
.join("/") || "homeassistant/status";
|
||||
|
||||
const buildMessage = (topic: string, qos: number): MQTTMessage => ({
|
||||
topic,
|
||||
payload: (PAYLOADS[topic] ?? PAYLOADS.default)(),
|
||||
qos,
|
||||
retain: 0,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// A filter matches a topic level by level: "+" stands for one level, "#" for
|
||||
// the rest of them.
|
||||
const filterMatches = (filter: string, topic: string): boolean => {
|
||||
const filterLevels = filter.split("/");
|
||||
const topicLevels = topic.split("/");
|
||||
for (let index = 0; index < filterLevels.length; index += 1) {
|
||||
if (filterLevels[index] === "#") {
|
||||
return true;
|
||||
}
|
||||
if (index >= topicLevels.length) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filterLevels[index] !== "+" &&
|
||||
filterLevels[index] !== topicLevels[index]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return filterLevels.length === topicLevels.length;
|
||||
};
|
||||
|
||||
// The panel's listen card and its publish button talk to each other through
|
||||
// the broker, so the mock keeps the subscriptions and delivers to them.
|
||||
const subscriptions = new Set<{
|
||||
filter: string;
|
||||
qos: number;
|
||||
deliver: (message: MQTTMessage) => void;
|
||||
}>();
|
||||
|
||||
const topicDebug = (topic: string) => ({
|
||||
topic,
|
||||
messages: [buildMessage(topic, 0)],
|
||||
});
|
||||
|
||||
// Keyed by device, the way the backend builds this per requested device.
|
||||
const DEBUG_INFO: Record<string, MQTTDeviceDebugInfo> = {
|
||||
"mqtt-fridge-sensor": {
|
||||
entities: [
|
||||
{
|
||||
entity_id: "sensor.fridge_temperature",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/sensor/fridge/temperature/config",
|
||||
payload: {
|
||||
name: "Temperature",
|
||||
state_topic: "zigbee2mqtt/fridge",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
|
||||
transmitted: [],
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.fridge_battery",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/sensor/fridge/battery/config",
|
||||
payload: {
|
||||
name: "Battery",
|
||||
state_topic: "zigbee2mqtt/fridge",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
|
||||
transmitted: [],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
"mqtt-garage-door": {
|
||||
entities: [
|
||||
{
|
||||
entity_id: "cover.garage_door",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/cover/garage/config",
|
||||
payload: {
|
||||
name: "Garage door",
|
||||
state_topic: "shellyplus1/status/cover:0",
|
||||
command_topic: "shellyplus1/command/cover:0",
|
||||
device_class: "garage",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("shellyplus1/status/cover:0")],
|
||||
transmitted: [topicDebug("shellyplus1/command/cover:0")],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const mockMqtt = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"mqtt/subscribe",
|
||||
(msg: { topic: string; qos?: number }, _hass, onChange) => {
|
||||
// Echo a message on the subscribed topic every few seconds so the
|
||||
// listen card in the MQTT panel shows traffic.
|
||||
const qos = msg.qos ?? 0;
|
||||
const topic = resolveFilter(msg.topic);
|
||||
const deliver = (message: MQTTMessage) => onChange?.(message);
|
||||
const subscription = { filter: msg.topic, qos, deliver };
|
||||
subscriptions.add(subscription);
|
||||
const send = () => deliver(buildMessage(topic, qos));
|
||||
const stopInitial = emitInitial(send);
|
||||
const interval = window.setInterval(send, 3000);
|
||||
return () => {
|
||||
stopInitial();
|
||||
clearInterval(interval);
|
||||
subscriptions.delete(subscription);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// The panel publishes through a script action rather than a `mqtt/` command,
|
||||
// so without this the publish button only ever reports a failure. Delivering
|
||||
// to the matching subscriptions is what makes the two halves of the panel
|
||||
// work together.
|
||||
hass.mockWS(
|
||||
"execute_script",
|
||||
(msg: { sequence: { action?: string; data?: Record<string, any> }[] }) => {
|
||||
msg.sequence
|
||||
?.filter((action) => action.action === "mqtt.publish")
|
||||
.forEach((action) => {
|
||||
const topic = String(action.data?.topic ?? "");
|
||||
const message: MQTTMessage = {
|
||||
topic,
|
||||
payload: String(action.data?.payload ?? ""),
|
||||
qos: Number(action.data?.qos ?? 0),
|
||||
retain: action.data?.retain ? 1 : 0,
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
subscriptions.forEach((subscription) => {
|
||||
if (filterMatches(subscription.filter, topic)) {
|
||||
subscription.deliver(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { context: { id: "mock-context" }, response: {} };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"mqtt/device/debug_info",
|
||||
(msg: { device_id: string }): MQTTDeviceDebugInfo =>
|
||||
DEBUG_INFO[msg.device_id] ?? { entities: [], triggers: [] }
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
minutesAgo,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-rf-bridge";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"rf-bridge-garage",
|
||||
"Garage bridge",
|
||||
"Sonoff",
|
||||
"RF Bridge R2",
|
||||
ENTRY_ID,
|
||||
{ area_id: "garden" }
|
||||
),
|
||||
device("rf-bridge-shed", "Shed bridge", "Sonoff", "RF Bridge R2", ENTRY_ID),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"radio_frequency.garage_bridge",
|
||||
"rf-bridge-garage",
|
||||
ENTRY_ID,
|
||||
"esphome",
|
||||
"Transceiver"
|
||||
),
|
||||
registryEntry(
|
||||
"radio_frequency.shed_bridge",
|
||||
"rf-bridge-shed",
|
||||
ENTRY_ID,
|
||||
"esphome",
|
||||
"Transceiver"
|
||||
),
|
||||
];
|
||||
|
||||
export const radioFrequencyFixtures: ConnectivityFixtures = {
|
||||
components: ["radio_frequency"],
|
||||
commands: ["radio_frequency/"],
|
||||
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
|
||||
configEntries: [
|
||||
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"radio_frequency.garage_bridge": {
|
||||
entity_id: "radio_frequency.garage_bridge",
|
||||
state: minutesAgo(47),
|
||||
attributes: { friendly_name: "Garage bridge Transceiver" },
|
||||
},
|
||||
"radio_frequency.shed_bridge": {
|
||||
entity_id: "radio_frequency.shed_bridge",
|
||||
state: "unknown",
|
||||
attributes: { friendly_name: "Shed bridge Transceiver" },
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
entity_component: {
|
||||
"component.radio_frequency.entity_component._.name": "Transceiver",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RadioFrequencyTransmitter } from "../../../../../src/data/radio_frequency";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
const TRANSMITTERS: RadioFrequencyTransmitter[] = [
|
||||
{
|
||||
entity_id: "radio_frequency.garage_bridge",
|
||||
device_id: "rf-bridge-garage",
|
||||
config_entry_id: "mock-rf-bridge",
|
||||
supported_frequency_ranges: [[433920000, 433920000]],
|
||||
supported_modulations: ["OOK"],
|
||||
},
|
||||
{
|
||||
entity_id: "radio_frequency.shed_bridge",
|
||||
device_id: "rf-bridge-shed",
|
||||
config_entry_id: "mock-rf-bridge",
|
||||
supported_frequency_ranges: [[433920000, 433920000]],
|
||||
supported_modulations: ["OOK"],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockRadioFrequency = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("radio_frequency/list", () => ({
|
||||
transmitters: TRANSMITTERS,
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const serialFixtures: ConnectivityFixtures = {
|
||||
components: ["usb"],
|
||||
commands: ["usb/"],
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { SerialPortUsage } from "../../../../../src/data/usb";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
const PORTS: SerialPortUsage[] = [
|
||||
{
|
||||
device: "/dev/ttyUSB0",
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-Nabu_Casa_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
|
||||
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
|
||||
manufacturer: "Nabu Casa",
|
||||
description: "Home Assistant Connect ZBT-1",
|
||||
interface_description: "Connect ZBT-1",
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: 256,
|
||||
matching_integrations: ["zha"],
|
||||
present: true,
|
||||
consumers: [
|
||||
{
|
||||
kind: "config_entry",
|
||||
title: "Home Assistant Connect ZBT-1",
|
||||
active: true,
|
||||
domain: "zha",
|
||||
config_entry_id: "mock-zha",
|
||||
slug: null,
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
device: "/dev/ttyACM0",
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-Zooz_800_Z-Wave_Stick_533D004242-if00",
|
||||
serial_number: "533D004242",
|
||||
manufacturer: "Zooz",
|
||||
description: "800 Series Z-Wave Long Range",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: 256,
|
||||
matching_integrations: ["zwave_js"],
|
||||
present: true,
|
||||
consumers: [
|
||||
{
|
||||
kind: "config_entry",
|
||||
title: "Z-Wave",
|
||||
active: true,
|
||||
domain: "zwave_js",
|
||||
config_entry_id: "mock-zwave-js",
|
||||
slug: null,
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
device: "/dev/ttyUSB1",
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
|
||||
serial_number: "A50285BI",
|
||||
manufacturer: "FTDI",
|
||||
description: "FT232R USB UART",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "0403",
|
||||
pid: "6001",
|
||||
bcd_device: 1536,
|
||||
matching_integrations: [],
|
||||
present: true,
|
||||
consumers: [],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
// A port that is configured but not currently plugged in. Its add-on icon
|
||||
// is requested straight from /api/hassio/addons/<slug>/icon, which the
|
||||
// demo has no backend for, so the icon stays blank here.
|
||||
device: "/dev/ttyUSB2",
|
||||
resolved_device: null,
|
||||
serial_number: "0001",
|
||||
manufacturer: "Silicon Labs",
|
||||
description: "CP2102 USB to UART Bridge Controller",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: null,
|
||||
matching_integrations: [],
|
||||
present: false,
|
||||
consumers: [
|
||||
{
|
||||
kind: "app",
|
||||
title: "ESPHome Device Builder",
|
||||
active: false,
|
||||
domain: null,
|
||||
config_entry_id: null,
|
||||
slug: "esphome",
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSerial = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("usb/list_serial_ports", () => PORTS);
|
||||
hass.mockWS("usb/scan", () => undefined);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// Mocked WebSocket subscriptions are registered synchronously, so a callback
|
||||
// invoked straight away can land before the subscriber is ready for it: pages
|
||||
// that ignore messages received before their first render drop it, and
|
||||
// `createCollection` overwrites it with the empty initial fetch. Emitting the
|
||||
// first message from a timeout matches the real backend, which always answers
|
||||
// asynchronously.
|
||||
export const emitInitial = (send: () => void): (() => void) => {
|
||||
const timeout = window.setTimeout(send, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const tagsFixtures: ConnectivityFixtures = {
|
||||
components: ["tag"],
|
||||
commands: ["tag/"],
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import { configEntry } from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const THREAD_ENTRY_ID = "mock-thread";
|
||||
const OTBR_ENTRY_ID = "mock-otbr";
|
||||
|
||||
export const threadFixtures: ConnectivityFixtures = {
|
||||
components: ["thread", "otbr"],
|
||||
commands: ["thread/", "otbr/"],
|
||||
manifests: [
|
||||
manifest("thread", "Thread", {
|
||||
integration_type: "service",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("otbr", "Open Thread Border Router", {
|
||||
integration_type: "service",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "service",
|
||||
entry: configEntry(THREAD_ENTRY_ID, "thread", "Thread"),
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: configEntry(OTBR_ENTRY_ID, "otbr", "Open Thread Border Router"),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,441 @@
|
||||
import type { OTBRInfoDict } from "../../../../../src/data/otbr";
|
||||
import type {
|
||||
ThreadDataSet,
|
||||
ThreadRouter,
|
||||
} from "../../../../../src/data/thread";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const HA_EXT_PAN_ID = "DEAD00BEEF00CAFE";
|
||||
const AMAZON_EXT_PAN_ID = "0011223344556677";
|
||||
|
||||
const OTBR_EXT_ADDRESS = "f6a1c30d2b4e5f61";
|
||||
const OTBR_BORDER_AGENT_ID = "230c6a1ac57f6f4be262acf32e5ef52c";
|
||||
|
||||
const ROUTERS: ThreadRouter[] = [
|
||||
{
|
||||
instance_name: "HomeAssistant OpenThreadBorderRouter",
|
||||
addresses: ["192.168.1.10"],
|
||||
border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
brand: "homeassistant",
|
||||
extended_address: OTBR_EXT_ADDRESS,
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "OpenThread Border Router",
|
||||
network_name: "ha-thread",
|
||||
server: "core-openthread-border-router.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Home Assistant",
|
||||
},
|
||||
{
|
||||
instance_name: "HomePod mini",
|
||||
addresses: ["192.168.1.24"],
|
||||
border_agent_id: "6a1ac57f6f4be262acf32e5ef52c230c",
|
||||
brand: "apple",
|
||||
extended_address: "aabbccddeeff0011",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "HomePod mini",
|
||||
network_name: "ha-thread",
|
||||
server: "homepod-mini.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Apple Inc.",
|
||||
},
|
||||
{
|
||||
instance_name: "Nest Hub",
|
||||
addresses: ["192.168.1.31"],
|
||||
border_agent_id: "ac57f6f4be262acf32e5ef52c230c6a1",
|
||||
brand: "google",
|
||||
extended_address: "bbccddeeff001122",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "Google Nest Hub",
|
||||
network_name: "ha-thread",
|
||||
server: "nest-hub.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Google Inc.",
|
||||
},
|
||||
{
|
||||
instance_name: "Echo (4th Gen)",
|
||||
addresses: ["192.168.1.42"],
|
||||
border_agent_id: "57f6f4be262acf32e5ef52c230c6a1ac",
|
||||
brand: "amazon",
|
||||
extended_address: "ccddeeff00112233",
|
||||
extended_pan_id: AMAZON_EXT_PAN_ID,
|
||||
model_name: "Echo",
|
||||
network_name: "AmazonThread",
|
||||
server: "amazon-echo.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Amazon",
|
||||
},
|
||||
];
|
||||
|
||||
const decodeUtf8 = (value: string): string | undefined => {
|
||||
const bytes = Uint8Array.from(
|
||||
(value.match(/../g) ?? []).map((byte) => parseInt(byte, 16))
|
||||
);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const tlv = (type: number, value: string) =>
|
||||
type.toString(16).padStart(2, "0").toUpperCase() +
|
||||
(value.length / 2).toString(16).padStart(2, "0").toUpperCase() +
|
||||
value.toUpperCase();
|
||||
|
||||
const textToHex = (text: string) =>
|
||||
Array.from(text)
|
||||
.map((character) => character.charCodeAt(0).toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const replaceTlvField = (value: string, type: number, replacement: string) => {
|
||||
let index = 0;
|
||||
let out = "";
|
||||
let replaced = false;
|
||||
while (index + 4 <= value.length) {
|
||||
const fieldType = parseInt(value.slice(index, index + 2), 16);
|
||||
const length = parseInt(value.slice(index + 2, index + 4), 16);
|
||||
const end = index + 4 + length * 2;
|
||||
out +=
|
||||
fieldType === type ? tlv(type, replacement) : value.slice(index, end);
|
||||
replaced = replaced || fieldType === type;
|
||||
index = end;
|
||||
}
|
||||
return replaced ? out : out + tlv(type, replacement);
|
||||
};
|
||||
|
||||
const buildDatasetTlv = (dataset: ThreadDataSet) =>
|
||||
[
|
||||
tlv(0x0e, "0000000000010000"),
|
||||
tlv(0x00, `00${(dataset.channel ?? 15).toString(16).padStart(4, "0")}`),
|
||||
tlv(0x35, "000040010200"),
|
||||
tlv(0x02, dataset.extended_pan_id),
|
||||
tlv(0x07, "FD11220000000000"),
|
||||
tlv(0x05, "00112233445566778899AABBCCDDEEFF"),
|
||||
dataset.network_name === null
|
||||
? ""
|
||||
: tlv(0x03, textToHex(dataset.network_name)),
|
||||
tlv(0x01, (dataset.pan_id ?? "1234").padStart(4, "0")),
|
||||
tlv(0x04, "1035060004001FFFE00C0402A0F7F800"),
|
||||
tlv(0x0c, "02A0F7F8"),
|
||||
].join("");
|
||||
|
||||
const parseDatasetTlv = (value: string) => {
|
||||
if (value.length % 2 !== 0 || !/^[0-9A-Fa-f]*$/.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const fields = new Map<number, string>();
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
if (index + 4 > value.length) {
|
||||
return undefined;
|
||||
}
|
||||
const type = parseInt(value.slice(index, index + 2), 16);
|
||||
const length = parseInt(value.slice(index + 2, index + 4), 16);
|
||||
const start = index + 4;
|
||||
const end = start + length * 2;
|
||||
if (end > value.length) {
|
||||
return undefined;
|
||||
}
|
||||
if (fields.has(type)) {
|
||||
return undefined;
|
||||
}
|
||||
fields.set(type, value.slice(start, end).toUpperCase());
|
||||
index = end;
|
||||
}
|
||||
const extendedPanId = fields.get(0x02);
|
||||
const networkName = fields.get(0x03);
|
||||
const activeTimestamp = fields.get(0x0e);
|
||||
if (
|
||||
!extendedPanId ||
|
||||
extendedPanId.length !== 16 ||
|
||||
!activeTimestamp ||
|
||||
activeTimestamp.length !== 16
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const decodedName =
|
||||
networkName === undefined ? null : decodeUtf8(networkName);
|
||||
if (decodedName === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = fields.get(0x00);
|
||||
const channelNumber = channel ? parseInt(channel.slice(2), 16) : null;
|
||||
if (channelNumber === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
activeTimestamp,
|
||||
extendedPanId,
|
||||
networkName: decodedName,
|
||||
panId: fields.get(0x01) ?? null,
|
||||
channel: channelNumber,
|
||||
};
|
||||
};
|
||||
|
||||
const DATASETS: ThreadDataSet[] = [
|
||||
{
|
||||
channel: 15,
|
||||
created: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
dataset_id: "ha-thread-dataset",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
network_name: "ha-thread",
|
||||
pan_id: "1234",
|
||||
preferred_border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
preferred_extended_address: OTBR_EXT_ADDRESS,
|
||||
preferred: true,
|
||||
source: "otbr",
|
||||
},
|
||||
];
|
||||
|
||||
const DATASET_TLVS: Record<string, string> = Object.fromEntries(
|
||||
DATASETS.map((dataset) => [dataset.dataset_id, buildDatasetTlv(dataset)])
|
||||
);
|
||||
|
||||
const OTBR_INFO: OTBRInfoDict = {
|
||||
[OTBR_EXT_ADDRESS]: {
|
||||
active_dataset_tlvs: DATASET_TLVS["ha-thread-dataset"],
|
||||
border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
channel: 15,
|
||||
extended_address: OTBR_EXT_ADDRESS,
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
url: "http://core-openthread-border-router:8081",
|
||||
},
|
||||
};
|
||||
|
||||
let added = 0;
|
||||
let created = 0;
|
||||
|
||||
const randomExtendedPanId = () =>
|
||||
Array.from({ length: 8 }, () =>
|
||||
Math.floor(Math.random() * 256)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const moveRouter = (
|
||||
extendedAddress: string,
|
||||
extendedPanId: string,
|
||||
networkName: string | null,
|
||||
datasetId: string
|
||||
) => {
|
||||
const info = OTBR_INFO[extendedAddress];
|
||||
const moved = DATASETS.find((item) => item.dataset_id === datasetId);
|
||||
if (info) {
|
||||
info.extended_pan_id = extendedPanId;
|
||||
info.active_dataset_tlvs =
|
||||
DATASET_TLVS[datasetId] ?? info.active_dataset_tlvs;
|
||||
info.channel = moved?.channel ?? info.channel;
|
||||
}
|
||||
const router = ROUTERS.find(
|
||||
(candidate) => candidate.extended_address === extendedAddress
|
||||
);
|
||||
if (router) {
|
||||
router.extended_pan_id = extendedPanId;
|
||||
router.network_name = networkName;
|
||||
announce(router);
|
||||
}
|
||||
};
|
||||
|
||||
type RouterListener = (event: {
|
||||
key: string;
|
||||
type: "router_discovered" | "router_removed";
|
||||
data: ThreadRouter;
|
||||
}) => void;
|
||||
|
||||
const listeners = new Set<RouterListener>();
|
||||
|
||||
const announce = (router: ThreadRouter) =>
|
||||
listeners.forEach((listener) =>
|
||||
listener({
|
||||
key: router.extended_address,
|
||||
type: "router_discovered",
|
||||
data: router,
|
||||
})
|
||||
);
|
||||
|
||||
export const mockThread = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("thread/discover_routers", (_msg, _hass, onChange) => {
|
||||
const listener = onChange as RouterListener | undefined;
|
||||
if (listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
const stopInitial = emitInitial(() => ROUTERS.forEach(announce));
|
||||
return () => {
|
||||
stopInitial();
|
||||
if (listener) {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
hass.mockWS("thread/list_datasets", () => ({
|
||||
datasets: DATASETS.map((dataset) => ({ ...dataset })),
|
||||
}));
|
||||
hass.mockWS("thread/get_dataset_tlv", (msg: { dataset_id: string }) => {
|
||||
const value = DATASET_TLVS[msg.dataset_id];
|
||||
if (!value) {
|
||||
throw new Error(`Dataset ${msg.dataset_id} not found`);
|
||||
}
|
||||
return { tlv: value };
|
||||
});
|
||||
|
||||
hass.mockWS("otbr/info", () => OTBR_INFO);
|
||||
|
||||
hass.mockWS(
|
||||
"thread/add_dataset_tlv",
|
||||
(msg: { source: string; tlv: string }) => {
|
||||
const parsed = parseDatasetTlv(msg.tlv);
|
||||
if (!parsed) {
|
||||
throw new Error("Invalid dataset");
|
||||
}
|
||||
const existing = DATASETS.find(
|
||||
(candidate) => candidate.extended_pan_id === parsed.extendedPanId
|
||||
);
|
||||
if (existing) {
|
||||
const current = parseDatasetTlv(DATASET_TLVS[existing.dataset_id]);
|
||||
if (current && parsed.activeTimestamp <= current.activeTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
existing.channel = parsed.channel;
|
||||
existing.network_name = parsed.networkName;
|
||||
existing.pan_id = parsed.panId;
|
||||
DATASET_TLVS[existing.dataset_id] = msg.tlv.toUpperCase();
|
||||
return undefined;
|
||||
}
|
||||
added += 1;
|
||||
const dataset: ThreadDataSet = {
|
||||
channel: parsed.channel,
|
||||
created: new Date().toISOString(),
|
||||
dataset_id: `added-dataset-${added}`,
|
||||
extended_pan_id: parsed.extendedPanId,
|
||||
network_name: parsed.networkName,
|
||||
pan_id: parsed.panId,
|
||||
preferred_border_agent_id: null,
|
||||
preferred_extended_address: null,
|
||||
preferred: false,
|
||||
source: msg.source,
|
||||
};
|
||||
DATASETS.push(dataset);
|
||||
DATASET_TLVS[dataset.dataset_id] = msg.tlv.toUpperCase();
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("thread/delete_dataset", (msg: { dataset_id: string }) => {
|
||||
const index = DATASETS.findIndex(
|
||||
(dataset) => dataset.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (index === -1) {
|
||||
throw new Error(`Dataset ${msg.dataset_id} not found`);
|
||||
}
|
||||
if (DATASETS[index].preferred) {
|
||||
throw new Error("Preferred dataset cannot be deleted");
|
||||
}
|
||||
DATASETS.splice(index, 1);
|
||||
delete DATASET_TLVS[msg.dataset_id];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS("thread/set_preferred_dataset", (msg: { dataset_id: string }) => {
|
||||
DATASETS.forEach((dataset) => {
|
||||
dataset.preferred = dataset.dataset_id === msg.dataset_id;
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"thread/set_preferred_border_agent",
|
||||
(msg: {
|
||||
dataset_id: string;
|
||||
border_agent_id: string | null;
|
||||
extended_address: string;
|
||||
}) => {
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (dataset) {
|
||||
dataset.preferred_border_agent_id = msg.border_agent_id;
|
||||
dataset.preferred_extended_address = msg.extended_address;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("otbr/create_network", (msg: { extended_address: string }) => {
|
||||
created += 1;
|
||||
const dataset: ThreadDataSet = {
|
||||
channel: 15,
|
||||
created: new Date().toISOString(),
|
||||
dataset_id: `created-dataset-${created}`,
|
||||
extended_pan_id: randomExtendedPanId(),
|
||||
network_name: `ha-thread-${created}`,
|
||||
pan_id: "1234",
|
||||
preferred_border_agent_id: null,
|
||||
preferred_extended_address: null,
|
||||
preferred: false,
|
||||
source: "otbr",
|
||||
};
|
||||
DATASETS.push(dataset);
|
||||
DATASET_TLVS[dataset.dataset_id] = buildDatasetTlv(dataset);
|
||||
moveRouter(
|
||||
msg.extended_address,
|
||||
dataset.extended_pan_id,
|
||||
dataset.network_name,
|
||||
dataset.dataset_id
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"otbr/set_network",
|
||||
(msg: { extended_address: string; dataset_id: string }) => {
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (dataset) {
|
||||
const info = OTBR_INFO[msg.extended_address];
|
||||
if (info) {
|
||||
info.channel = dataset.channel ?? info.channel;
|
||||
}
|
||||
moveRouter(
|
||||
msg.extended_address,
|
||||
dataset.extended_pan_id,
|
||||
dataset.network_name,
|
||||
dataset.dataset_id
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"otbr/set_channel",
|
||||
(msg: { extended_address: string; channel: number }) => {
|
||||
const info = OTBR_INFO[msg.extended_address];
|
||||
if (info) {
|
||||
info.channel = msg.channel;
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.extended_pan_id === info.extended_pan_id
|
||||
);
|
||||
if (dataset) {
|
||||
dataset.channel = msg.channel;
|
||||
DATASET_TLVS[dataset.dataset_id] = replaceTlvField(
|
||||
DATASET_TLVS[dataset.dataset_id],
|
||||
0x00,
|
||||
`00${msg.channel.toString(16).padStart(4, "0")}`
|
||||
);
|
||||
info.active_dataset_tlvs = DATASET_TLVS[dataset.dataset_id];
|
||||
}
|
||||
}
|
||||
return { delay: 120 };
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ConfigEntry } from "../../../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type {
|
||||
IntegrationManifest,
|
||||
IntegrationType,
|
||||
} from "../../../../src/data/integration";
|
||||
import type { TranslationCategory } from "../../../../src/data/translation";
|
||||
import type { EntityInput } from "../../../../src/fake_data/entities/types";
|
||||
|
||||
export interface DemoConfigEntry {
|
||||
entry: ConfigEntry;
|
||||
type: IntegrationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one connectivity integration contributes to the demo besides its
|
||||
* WebSocket mocks. This is loaded eagerly, together with the registries, so it
|
||||
* must not pull in the mocks (which are code-split into the config panel
|
||||
* chunk). Each integration owns one of these, so they stay independent.
|
||||
*/
|
||||
export interface ConnectivityFixtures {
|
||||
/** Components to load, so the integration's panel is reachable. */
|
||||
components: string[];
|
||||
/** WS command prefixes served by the integration's mock, if it has one. */
|
||||
commands?: string[];
|
||||
configEntries?: DemoConfigEntry[];
|
||||
/** Manifests for the domains above, so their integration pages open. */
|
||||
manifests?: IntegrationManifest[];
|
||||
devices?: DeviceRegistryEntry[];
|
||||
entityRegistryEntries?: EntityRegistryEntry[];
|
||||
/** States for the entities above; built lazily so timestamps stay fresh. */
|
||||
entities?: () => EntityInput[];
|
||||
/** Backend translations the panel looks up, by category. */
|
||||
backendTranslations?: Partial<
|
||||
Record<TranslationCategory, Record<string, string>>
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-zha";
|
||||
|
||||
export const COORDINATOR_IEEE = "00:12:4b:00:24:c2:e1:00";
|
||||
export const PORCH_IEEE = "84:2e:14:ff:fe:11:22:33";
|
||||
export const MOTION_IEEE = "cc:cc:cc:ff:fe:44:55:66";
|
||||
export const PLUG_IEEE = "00:15:8d:00:03:aa:bb:cc";
|
||||
export const KITCHEN_IEEE = "84:2e:14:ff:fe:aa:bb:01";
|
||||
export const LANDING_IEEE = "00:15:8d:00:03:aa:bb:02";
|
||||
export const GARAGE_IEEE = "00:15:8d:00:03:aa:bb:03";
|
||||
export const OFFICE_IEEE = "00:15:8d:00:03:aa:bb:04";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"zha-coordinator",
|
||||
"Home Assistant Connect ZBT-1",
|
||||
"Nabu Casa",
|
||||
"Connect ZBT-1",
|
||||
ENTRY_ID,
|
||||
{
|
||||
sw_version: "7.4.4.0",
|
||||
connections: [["zigbee", COORDINATOR_IEEE]],
|
||||
identifiers: [["zha", COORDINATOR_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-porch-light",
|
||||
"Porch light",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI bulb E27 CWS 806lm",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", PORCH_IEEE]],
|
||||
identifiers: [["zha", PORCH_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-hall-motion",
|
||||
"Hall motion",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI motion sensor",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", MOTION_IEEE]],
|
||||
identifiers: [["zha", MOTION_IEEE]],
|
||||
}
|
||||
),
|
||||
device("zha-tv-plug", "TV plug", "Innr", "SP 220", ENTRY_ID, {
|
||||
area_id: "living_room",
|
||||
connections: [["zigbee", PLUG_IEEE]],
|
||||
identifiers: [["zha", PLUG_IEEE]],
|
||||
}),
|
||||
device(
|
||||
"zha-kitchen-switch",
|
||||
"Kitchen switch",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI on/off switch",
|
||||
ENTRY_ID,
|
||||
{
|
||||
area_id: "kitchen",
|
||||
connections: [["zigbee", KITCHEN_IEEE]],
|
||||
identifiers: [["zha", KITCHEN_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-landing-sensor",
|
||||
"Landing sensor",
|
||||
"Aqara",
|
||||
"WSDCGQ11LM",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", LANDING_IEEE]],
|
||||
identifiers: [["zha", LANDING_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-garage-contact",
|
||||
"Garage contact",
|
||||
"Aqara",
|
||||
"MCCGQ11LM",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", GARAGE_IEEE]],
|
||||
identifiers: [["zha", GARAGE_IEEE]],
|
||||
}
|
||||
),
|
||||
device("zha-office-plug", "Office plug", "Innr", "SP 240", ENTRY_ID, {
|
||||
area_id: "office",
|
||||
connections: [["zigbee", OFFICE_IEEE]],
|
||||
identifiers: [["zha", OFFICE_IEEE]],
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry("light.porch", "zha-porch-light", ENTRY_ID, "zha"),
|
||||
registryEntry(
|
||||
"binary_sensor.hall_motion",
|
||||
"zha-hall-motion",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry("switch.tv_plug", "zha-tv-plug", ENTRY_ID, "zha"),
|
||||
registryEntry(
|
||||
"sensor.kitchen_switch_battery",
|
||||
"zha-kitchen-switch",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.landing_temperature",
|
||||
"zha-landing-sensor",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry(
|
||||
"binary_sensor.garage_contact",
|
||||
"zha-garage-contact",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry("switch.office_desk", "zha-office-plug", ENTRY_ID, "zha"),
|
||||
];
|
||||
|
||||
export const AREA_BY_IEEE: Record<string, string> = Object.fromEntries(
|
||||
DEVICES.flatMap((entry) => {
|
||||
const areaId = entry.area_id;
|
||||
return areaId
|
||||
? entry.connections
|
||||
.filter(([type]) => type === "zigbee")
|
||||
.map(([, ieee]) => [ieee, areaId])
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
export const zhaFixtures: ConnectivityFixtures = {
|
||||
components: ["zha"],
|
||||
commands: ["zha/"],
|
||||
manifests: [
|
||||
manifest("zha", "Zigbee Home Automation", {
|
||||
integration_type: "hub",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "zha", "Home Assistant Connect ZBT-1", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.porch": {
|
||||
entity_id: "light.porch",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Porch light",
|
||||
supported_color_modes: ["hs"],
|
||||
},
|
||||
},
|
||||
"binary_sensor.hall_motion": {
|
||||
entity_id: "binary_sensor.hall_motion",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Hall motion", device_class: "motion" },
|
||||
},
|
||||
"switch.tv_plug": {
|
||||
entity_id: "switch.tv_plug",
|
||||
state: "unavailable",
|
||||
attributes: { friendly_name: "TV plug" },
|
||||
},
|
||||
"sensor.kitchen_switch_battery": {
|
||||
entity_id: "sensor.kitchen_switch_battery",
|
||||
state: "78",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen switch battery",
|
||||
device_class: "battery",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"sensor.landing_temperature": {
|
||||
entity_id: "sensor.landing_temperature",
|
||||
state: "19.6",
|
||||
attributes: {
|
||||
friendly_name: "Landing temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"binary_sensor.garage_contact": {
|
||||
entity_id: "binary_sensor.garage_contact",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Garage contact", device_class: "door" },
|
||||
},
|
||||
"switch.office_desk": {
|
||||
entity_id: "switch.office_desk",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Office desk" },
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
config_panel: {
|
||||
"component.zha.config_panel.zha_options.title": "Global options",
|
||||
"component.zha.config_panel.zha_options.default_light_transition":
|
||||
"Default light transition time (seconds)",
|
||||
"component.zha.config_panel.zha_options.enhanced_light_transition":
|
||||
"Enable enhanced light color/temperature transition from an off state",
|
||||
"component.zha.config_panel.zha_options.always_prefer_xy_color_mode":
|
||||
"Always prefer XY color mode",
|
||||
"component.zha.config_panel.zha_alarm_options.title": "Alarm options",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_master_code":
|
||||
"Alarm master code",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_failed_tries":
|
||||
"Failed authentication attempts before restart",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_arm_requires_code":
|
||||
"Code required for arming",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,793 @@
|
||||
import type {
|
||||
Attribute,
|
||||
AttributeConfigurationStatus,
|
||||
Cluster,
|
||||
ClusterConfigurationEvent,
|
||||
Command,
|
||||
Neighbor,
|
||||
ReadAttributeServiceData,
|
||||
ZHAConfiguration,
|
||||
ZHADevice,
|
||||
ZHADeviceEndpoint,
|
||||
ZHAGroup,
|
||||
ZHAEntityReference,
|
||||
ZHAGroupMember,
|
||||
ZHANetworkBackup,
|
||||
ZHANetworkSettings,
|
||||
} from "../../../../../src/data/zha";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { minutesAgo } from "../helpers";
|
||||
|
||||
import {
|
||||
AREA_BY_IEEE,
|
||||
LANDING_IEEE,
|
||||
COORDINATOR_IEEE,
|
||||
GARAGE_IEEE,
|
||||
KITCHEN_IEEE,
|
||||
MOTION_IEEE,
|
||||
OFFICE_IEEE,
|
||||
PLUG_IEEE,
|
||||
PORCH_IEEE,
|
||||
} from "./fixtures";
|
||||
|
||||
const neighbor = (
|
||||
ieee: string,
|
||||
nwk: string,
|
||||
lqi: string,
|
||||
relationship: string,
|
||||
depth = "1"
|
||||
): Neighbor => ({ ieee, nwk, lqi, depth, relationship });
|
||||
|
||||
const DEVICES: ZHADevice[] = [
|
||||
{
|
||||
available: true,
|
||||
name: "Nabu Casa Connect ZBT-1",
|
||||
ieee: COORDINATOR_IEEE,
|
||||
nwk: 0x0000,
|
||||
lqi: 255,
|
||||
rssi: "0",
|
||||
last_seen: minutesAgo(0),
|
||||
manufacturer: "Nabu Casa",
|
||||
model: "Connect ZBT-1",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-coordinator",
|
||||
user_given_name: "Home Assistant Connect ZBT-1",
|
||||
power_source: "Mains",
|
||||
device_type: "Coordinator",
|
||||
active_coordinator: true,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(PORCH_IEEE, "0x1a2b", "224", "Child"),
|
||||
neighbor(PLUG_IEEE, "0x3c4d", "198", "Child"),
|
||||
neighbor(OFFICE_IEEE, "0x7a8b", "211", "Child"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI bulb E27 CWS 806lm",
|
||||
ieee: PORCH_IEEE,
|
||||
nwk: 0x1a2b,
|
||||
lqi: 224,
|
||||
rssi: "-58",
|
||||
last_seen: minutesAgo(2),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI bulb E27 CWS 806lm",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.ikea.bulb.IkeaBulb",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-porch-light",
|
||||
user_given_name: "Porch light",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(COORDINATOR_IEEE, "0x0000", "224", "Parent", "0"),
|
||||
neighbor(MOTION_IEEE, "0x5e6f", "142", "Child", "2"),
|
||||
neighbor(KITCHEN_IEEE, "0x9c0d", "186", "Sibling", "1"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI motion sensor",
|
||||
ieee: MOTION_IEEE,
|
||||
nwk: 0x5e6f,
|
||||
lqi: 142,
|
||||
rssi: "-77",
|
||||
last_seen: minutesAgo(9),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI motion sensor",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-hall-motion",
|
||||
user_given_name: "Hall motion",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: false,
|
||||
name: "Innr SP 220",
|
||||
ieee: PLUG_IEEE,
|
||||
nwk: 0x3c4d,
|
||||
lqi: 198,
|
||||
rssi: "-64",
|
||||
last_seen: minutesAgo(240),
|
||||
manufacturer: "Innr",
|
||||
model: "SP 220",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4448,
|
||||
device_reg_id: "zha-tv-plug",
|
||||
user_given_name: "TV plug",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI on/off switch",
|
||||
ieee: KITCHEN_IEEE,
|
||||
nwk: 0x9c0d,
|
||||
lqi: 186,
|
||||
rssi: "-69",
|
||||
last_seen: minutesAgo(4),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI on/off switch",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.ikea.onoffswitch.IkeaSwitch",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-kitchen-switch",
|
||||
user_given_name: "Kitchen switch",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Aqara temperature sensor",
|
||||
ieee: LANDING_IEEE,
|
||||
nwk: 0xab12,
|
||||
lqi: 164,
|
||||
rssi: "-74",
|
||||
last_seen: minutesAgo(6),
|
||||
manufacturer: "Aqara",
|
||||
model: "WSDCGQ11LM",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.xiaomi.aqara.weather.Weather",
|
||||
entities: [],
|
||||
manufacturer_code: 4447,
|
||||
device_reg_id: "zha-landing-sensor",
|
||||
user_given_name: "Landing sensor",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Aqara door sensor",
|
||||
ieee: GARAGE_IEEE,
|
||||
nwk: 0xcd34,
|
||||
lqi: 118,
|
||||
rssi: "-83",
|
||||
last_seen: minutesAgo(21),
|
||||
manufacturer: "Aqara",
|
||||
model: "MCCGQ11LM",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.xiaomi.aqara.magnet.Magnet",
|
||||
entities: [],
|
||||
manufacturer_code: 4447,
|
||||
device_reg_id: "zha-garage-contact",
|
||||
user_given_name: "Garage contact",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Innr SP 240",
|
||||
ieee: OFFICE_IEEE,
|
||||
nwk: 0x7a8b,
|
||||
lqi: 211,
|
||||
rssi: "-61",
|
||||
last_seen: minutesAgo(1),
|
||||
manufacturer: "Innr",
|
||||
model: "SP 240",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4448,
|
||||
device_reg_id: "zha-office-plug",
|
||||
user_given_name: "Office plug",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(COORDINATOR_IEEE, "0x0000", "211", "Parent", "0"),
|
||||
neighbor(LANDING_IEEE, "0xab12", "164", "Child", "2"),
|
||||
neighbor(GARAGE_IEEE, "0xcd34", "118", "Child", "2"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
];
|
||||
|
||||
DEVICES.forEach((zhaDevice) => {
|
||||
zhaDevice.area_id = AREA_BY_IEEE[zhaDevice.ieee];
|
||||
});
|
||||
|
||||
const deviceByIeee = (ieee: string): ZHADevice =>
|
||||
DEVICES.find((d) => d.ieee === ieee)!;
|
||||
|
||||
const ENDPOINT_ENTITIES: Record<string, { entity_id: string; name: string }[]> =
|
||||
{
|
||||
[PORCH_IEEE]: [{ entity_id: "light.porch", name: "Porch light" }],
|
||||
[PLUG_IEEE]: [{ entity_id: "switch.tv_plug", name: "TV plug" }],
|
||||
[OFFICE_IEEE]: [{ entity_id: "switch.office_desk", name: "Office desk" }],
|
||||
};
|
||||
|
||||
const member = (ieee: string, endpointId = 1): ZHADeviceEndpoint => ({
|
||||
device: deviceByIeee(ieee),
|
||||
endpoint_id: endpointId,
|
||||
entities: (ENDPOINT_ENTITIES[ieee] ?? []).map(
|
||||
(entity) =>
|
||||
({
|
||||
...entity,
|
||||
original_name: entity.name,
|
||||
}) as ZHAEntityReference
|
||||
),
|
||||
});
|
||||
|
||||
const GROUPS: ZHAGroup[] = [
|
||||
{
|
||||
name: "Downstairs lights",
|
||||
group_id: 1,
|
||||
members: [member(PORCH_IEEE), member(OFFICE_IEEE)],
|
||||
},
|
||||
{
|
||||
name: "Outdoor lights",
|
||||
group_id: 2,
|
||||
members: [member(PORCH_IEEE)],
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGURATION: ZHAConfiguration = {
|
||||
data: {
|
||||
zha_options: {
|
||||
default_light_transition: 0,
|
||||
enhanced_light_transition: false,
|
||||
light_transitioning_flag: true,
|
||||
always_prefer_xy_color_mode: true,
|
||||
group_members_assume_state: true,
|
||||
consider_unavailable_mains: 7200,
|
||||
consider_unavailable_battery: 21600,
|
||||
},
|
||||
zha_alarm_options: {
|
||||
alarm_master_code: "1234",
|
||||
alarm_failed_tries: 3,
|
||||
alarm_arm_requires_code: false,
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
zha_options: [
|
||||
{
|
||||
name: "default_light_transition",
|
||||
required: true,
|
||||
selector: { number: { min: 0, max: 2 ** 16 / 10, step: 0.1 } },
|
||||
},
|
||||
{
|
||||
name: "enhanced_light_transition",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "always_prefer_xy_color_mode",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
zha_alarm_options: [
|
||||
{ name: "alarm_master_code", required: true, selector: { text: {} } },
|
||||
{
|
||||
name: "alarm_failed_tries",
|
||||
required: true,
|
||||
selector: { number: { min: 0, max: 2 ** 8, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "alarm_arm_requires_code",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const BACKUPS: ZHANetworkBackup[] = [];
|
||||
|
||||
const NETWORK_SETTINGS: ZHANetworkSettings = {
|
||||
radio_type: "ezsp",
|
||||
device: { path: "/dev/ttyUSB0", baudrate: 115200, flow_control: "hardware" },
|
||||
settings: {
|
||||
backup_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
node_info: {
|
||||
nwk: "0x0000",
|
||||
ieee: COORDINATOR_IEEE,
|
||||
logical_type: "coordinator",
|
||||
},
|
||||
network_info: {
|
||||
extended_pan_id: "b0:23:2f:cc:aa:11:22:33",
|
||||
pan_id: "0x1234",
|
||||
nwk_update_id: 0,
|
||||
nwk_manager_id: "0x0000",
|
||||
channel: 15,
|
||||
channel_mask: [15, 20, 25],
|
||||
security_level: 5,
|
||||
network_key: {
|
||||
key: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
tx_counter: 138234,
|
||||
rx_counter: 0,
|
||||
seq: 0,
|
||||
partner_ieee: "ff:ff:ff:ff:ff:ff:ff:ff",
|
||||
},
|
||||
tc_link_key: {
|
||||
key: "5a:69:67:42:65:65:41:6c:6c:69:61:6e:63:65:30:39",
|
||||
tx_counter: 0,
|
||||
rx_counter: 0,
|
||||
seq: 0,
|
||||
partner_ieee: COORDINATOR_IEEE,
|
||||
},
|
||||
key_table: [],
|
||||
children: [PORCH_IEEE, PLUG_IEEE, OFFICE_IEEE],
|
||||
nwk_addresses: {
|
||||
[PORCH_IEEE]: "0x1a2b",
|
||||
[MOTION_IEEE]: "0x5e6f",
|
||||
[PLUG_IEEE]: "0x3c4d",
|
||||
[KITCHEN_IEEE]: "0x9c0d",
|
||||
[LANDING_IEEE]: "0xab12",
|
||||
[GARAGE_IEEE]: "0xcd34",
|
||||
[OFFICE_IEEE]: "0x7a8b",
|
||||
},
|
||||
stack_specific: {},
|
||||
metadata: { ezsp: { stack_version: "7.4.4.0" } },
|
||||
source: "[email protected]",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface ClusterDefinition {
|
||||
name: string;
|
||||
attributes: Attribute[];
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
const numberField = (name: string, max: number) => ({
|
||||
name,
|
||||
required: true,
|
||||
selector: { number: { min: 0, max, mode: "box" as const } },
|
||||
});
|
||||
|
||||
const CLUSTERS: Record<number, ClusterDefinition> = {
|
||||
0: {
|
||||
name: "Basic",
|
||||
attributes: [
|
||||
{ name: "zcl_version", id: 0 },
|
||||
{ name: "app_version", id: 1 },
|
||||
{ name: "manufacturer", id: 4 },
|
||||
{ name: "model", id: 5 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
1: {
|
||||
name: "PowerConfiguration",
|
||||
attributes: [
|
||||
{ name: "battery_voltage", id: 32 },
|
||||
{ name: "battery_percentage_remaining", id: 33 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
3: {
|
||||
name: "Identify",
|
||||
attributes: [{ name: "identify_time", id: 0 }],
|
||||
commands: [
|
||||
{
|
||||
name: "identify",
|
||||
id: 0,
|
||||
type: "server",
|
||||
schema: [numberField("identify_time", 65535)],
|
||||
},
|
||||
],
|
||||
},
|
||||
4: {
|
||||
name: "Groups",
|
||||
attributes: [{ name: "name_support", id: 0 }],
|
||||
commands: [],
|
||||
},
|
||||
6: {
|
||||
name: "OnOff",
|
||||
attributes: [{ name: "on_off", id: 0 }],
|
||||
commands: [
|
||||
{ name: "off", id: 0, type: "server", schema: [] },
|
||||
{ name: "on", id: 1, type: "server", schema: [] },
|
||||
{ name: "toggle", id: 2, type: "server", schema: [] },
|
||||
],
|
||||
},
|
||||
8: {
|
||||
name: "LevelControl",
|
||||
attributes: [{ name: "current_level", id: 0 }],
|
||||
commands: [
|
||||
{
|
||||
name: "move_to_level",
|
||||
id: 0,
|
||||
type: "server",
|
||||
schema: [
|
||||
numberField("level", 254),
|
||||
numberField("transition_time", 65535),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
25: {
|
||||
name: "Ota",
|
||||
attributes: [{ name: "current_file_version", id: 2 }],
|
||||
commands: [],
|
||||
},
|
||||
768: {
|
||||
name: "ColorControl",
|
||||
attributes: [
|
||||
{ name: "current_hue", id: 0 },
|
||||
{ name: "current_saturation", id: 1 },
|
||||
{ name: "color_temperature", id: 7 },
|
||||
],
|
||||
commands: [
|
||||
{
|
||||
name: "move_to_color_temp",
|
||||
id: 10,
|
||||
type: "server",
|
||||
schema: [
|
||||
numberField("color_temp_mireds", 500),
|
||||
numberField("transition_time", 65535),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
1026: {
|
||||
name: "TemperatureMeasurement",
|
||||
attributes: [
|
||||
{ name: "measured_value", id: 0 },
|
||||
{ name: "min_measured_value", id: 1 },
|
||||
{ name: "max_measured_value", id: 2 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
1280: {
|
||||
name: "IasZone",
|
||||
attributes: [
|
||||
{ name: "zone_state", id: 0 },
|
||||
{ name: "zone_type", id: 1 },
|
||||
{ name: "zone_status", id: 2 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
2820: {
|
||||
name: "ElectricalMeasurement",
|
||||
attributes: [
|
||||
{ name: "rms_voltage", id: 1285 },
|
||||
{ name: "rms_current", id: 1288 },
|
||||
{ name: "active_power", id: 1291 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
};
|
||||
|
||||
const clusterList = (inIds: number[], outIds: number[] = []): Cluster[] =>
|
||||
[
|
||||
...inIds.map((id) => ({ id, type: "in" })),
|
||||
...outIds.map((id) => ({ id, type: "out" })),
|
||||
].map(({ id, type }) => ({
|
||||
name: CLUSTERS[id].name,
|
||||
id,
|
||||
endpoint_id: 1,
|
||||
type,
|
||||
}));
|
||||
|
||||
// A device binds from its client side, and group binding lists only those, so
|
||||
// the remotes carry the `out` clusters they would have on real hardware and
|
||||
// the mains-powered devices only their OTA one.
|
||||
const DEVICE_CLUSTERS: Record<string, Cluster[]> = {
|
||||
[COORDINATOR_IEEE]: clusterList([0]),
|
||||
[PORCH_IEEE]: clusterList([0, 3, 4, 6, 8, 768], [25]),
|
||||
[MOTION_IEEE]: clusterList([0, 1, 3, 1280], [3, 6, 8]),
|
||||
[PLUG_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
|
||||
[KITCHEN_IEEE]: clusterList([0, 1, 3], [3, 6, 8]),
|
||||
[LANDING_IEEE]: clusterList([0, 1, 3, 1026]),
|
||||
[GARAGE_IEEE]: clusterList([0, 1, 3, 1280]),
|
||||
[OFFICE_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
|
||||
};
|
||||
|
||||
const DEFAULT_ATTRIBUTE_VALUES: Record<string, string> = {
|
||||
"1:32": "30",
|
||||
"1:33": "184",
|
||||
"3:0": "0",
|
||||
"4:0": "0",
|
||||
"6:0": "1",
|
||||
"8:0": "254",
|
||||
"768:0": "42",
|
||||
"768:1": "180",
|
||||
"768:7": "370",
|
||||
"1026:0": "2140",
|
||||
"1026:1": "-2000",
|
||||
"1026:2": "6000",
|
||||
"1280:0": "1",
|
||||
"1280:1": "21",
|
||||
"1280:2": "0",
|
||||
"2820:1285": "2300",
|
||||
"2820:1288": "410",
|
||||
"2820:1291": "94",
|
||||
};
|
||||
|
||||
const writtenAttributes = new Map<string, string>();
|
||||
|
||||
const attributeKey = (data: ReadAttributeServiceData) =>
|
||||
`${data.ieee}:${data.endpoint_id}:${data.cluster_id}:${data.attribute}`;
|
||||
|
||||
const attributeValue = (data: ReadAttributeServiceData): string => {
|
||||
const written = writtenAttributes.get(attributeKey(data));
|
||||
if (written !== undefined) {
|
||||
return written;
|
||||
}
|
||||
if (data.cluster_id === 0) {
|
||||
const device = DEVICES.find((candidate) => candidate.ieee === data.ieee);
|
||||
if (data.attribute === 4) {
|
||||
return device?.manufacturer ?? "";
|
||||
}
|
||||
if (data.attribute === 5) {
|
||||
return device?.model ?? "";
|
||||
}
|
||||
return "3";
|
||||
}
|
||||
return (
|
||||
DEFAULT_ATTRIBUTE_VALUES[`${data.cluster_id}:${data.attribute}`] ?? "0"
|
||||
);
|
||||
};
|
||||
|
||||
export const mockZha = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zha/devices", () => DEVICES);
|
||||
hass.mockWS("zha/device", (msg: { ieee: string }) =>
|
||||
DEVICES.find((device) => device.ieee === msg.ieee)
|
||||
);
|
||||
hass.mockWS("zha/groups", () => GROUPS);
|
||||
hass.mockWS("zha/group", (msg: { group_id: number }) =>
|
||||
GROUPS.find((group) => group.group_id === msg.group_id)
|
||||
);
|
||||
// Copied: both options editors mutate the fetched data as the user changes a
|
||||
// control, so handing out the backing object would persist edits that were
|
||||
// never saved. Only the update below writes to it.
|
||||
hass.mockWS("zha/configuration", () => structuredClone(CONFIGURATION));
|
||||
hass.mockWS("zha/network/settings", () => NETWORK_SETTINGS);
|
||||
hass.mockWS("zha/topology/update", () => undefined);
|
||||
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
|
||||
DEVICES.filter(
|
||||
(candidate) =>
|
||||
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
|
||||
)
|
||||
);
|
||||
hass.mockWS("zha/devices/bind", () => undefined);
|
||||
hass.mockWS("zha/devices/unbind", () => undefined);
|
||||
hass.mockWS("zha/groups/bind", () => undefined);
|
||||
hass.mockWS("zha/groups/unbind", () => undefined);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters",
|
||||
(msg: { ieee: string }) => DEVICE_CLUSTERS[msg.ieee] ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/attributes",
|
||||
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.attributes ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/commands",
|
||||
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.commands ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/attributes/value",
|
||||
(msg: ReadAttributeServiceData) => attributeValue(msg)
|
||||
);
|
||||
hass.mockService("zha", "set_zigbee_cluster_attribute", (data) => {
|
||||
const write = data as ReadAttributeServiceData & { value: unknown };
|
||||
writtenAttributes.set(attributeKey(write), String(write.value));
|
||||
return undefined;
|
||||
});
|
||||
hass.mockWS("zha/devices/groupable", () => [
|
||||
member(PORCH_IEEE),
|
||||
member(OFFICE_IEEE),
|
||||
member(PLUG_IEEE),
|
||||
]);
|
||||
hass.mockWS("zha/network/backups/list", () => BACKUPS);
|
||||
|
||||
hass.mockWS("zha/devices/permit", () => () => undefined);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/devices/reconfigure",
|
||||
(msg: { ieee: string }, _hass, onChange) => {
|
||||
const deviceClusters = DEVICE_CLUSTERS[msg.ieee] ?? [];
|
||||
const timers: number[] = [];
|
||||
let cancelled = false;
|
||||
const emit = (event: ClusterConfigurationEvent, step: number) => {
|
||||
timers.push(
|
||||
window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
onChange!(event);
|
||||
}
|
||||
}, step * 400)
|
||||
);
|
||||
};
|
||||
|
||||
deviceClusters.forEach((cluster, index) => {
|
||||
emit(
|
||||
{
|
||||
type: "zha_channel_bind",
|
||||
zha_channel_msg_data: {
|
||||
cluster_name: cluster.name,
|
||||
cluster_id: cluster.id,
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
index + 1
|
||||
);
|
||||
const attributes: AttributeConfigurationStatus[] = CLUSTERS[
|
||||
cluster.id
|
||||
].attributes.map((attribute) => ({
|
||||
...attribute,
|
||||
status: "SUCCESS",
|
||||
min: 30,
|
||||
max: 900,
|
||||
change: 1,
|
||||
}));
|
||||
if (attributes.length) {
|
||||
emit(
|
||||
{
|
||||
type: "zha_channel_configure_reporting",
|
||||
zha_channel_msg_data: {
|
||||
cluster_name: cluster.name,
|
||||
cluster_id: cluster.id,
|
||||
attributes,
|
||||
},
|
||||
},
|
||||
index + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
emit({ type: "zha_channel_cfg_done" }, deviceClusters.length + 1);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/configuration/update",
|
||||
(msg: { data: ZHAConfiguration["data"] }) => {
|
||||
Object.entries(msg.data ?? {}).forEach(([section, values]) => {
|
||||
CONFIGURATION.data[section] = {
|
||||
...CONFIGURATION.data[section],
|
||||
...values,
|
||||
};
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("zha/network/backups/create", () => {
|
||||
const backup: ZHANetworkBackup = {
|
||||
backup_time: new Date().toISOString(),
|
||||
// Copied, or changing the channel afterwards would rewrite the backup
|
||||
// too, which is the one thing a backup must not do.
|
||||
network_info: structuredClone(NETWORK_SETTINGS.settings.network_info),
|
||||
node_info: structuredClone(NETWORK_SETTINGS.settings.node_info),
|
||||
};
|
||||
BACKUPS.push(backup);
|
||||
return { backup, is_complete: true };
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"zha/network/change_channel",
|
||||
(msg: { new_channel: "auto" | number }) => {
|
||||
NETWORK_SETTINGS.settings.network_info.channel =
|
||||
msg.new_channel === "auto" ? 25 : msg.new_channel;
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/add",
|
||||
(msg: {
|
||||
group_name: string;
|
||||
group_id?: number;
|
||||
members?: ZHAGroupMember[];
|
||||
}) => {
|
||||
const group: ZHAGroup = {
|
||||
name: msg.group_name,
|
||||
group_id:
|
||||
msg.group_id ??
|
||||
GROUPS.reduce(
|
||||
(highest, item) => Math.max(highest, item.group_id),
|
||||
0
|
||||
) + 1,
|
||||
members: (msg.members ?? []).map((item) => member(item.ieee)),
|
||||
};
|
||||
GROUPS.push(group);
|
||||
return group;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("zha/group/remove", (msg: { group_ids: number[] }) => {
|
||||
msg.group_ids.forEach((groupId) => {
|
||||
const index = GROUPS.findIndex((group) => group.group_id === groupId);
|
||||
if (index !== -1) {
|
||||
GROUPS.splice(index, 1);
|
||||
}
|
||||
});
|
||||
return GROUPS;
|
||||
});
|
||||
|
||||
const findGroup = (groupId: number) => {
|
||||
const group = GROUPS.find((candidate) => candidate.group_id === groupId);
|
||||
if (!group) {
|
||||
throw new Error(`Group ${groupId} not found`);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/members/add",
|
||||
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
|
||||
const group = findGroup(msg.group_id);
|
||||
const known = new Set(group.members.map((item) => item.device.ieee));
|
||||
group.members = [
|
||||
...group.members,
|
||||
...msg.members
|
||||
.filter((item) => !known.has(item.ieee))
|
||||
.map((item) => member(item.ieee)),
|
||||
];
|
||||
return group;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/members/remove",
|
||||
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
|
||||
const group = findGroup(msg.group_id);
|
||||
const dropped = new Set(msg.members.map((item) => item.ieee));
|
||||
group.members = group.members.filter(
|
||||
(item) => !dropped.has(item.device.ieee)
|
||||
);
|
||||
return group;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-zwave-js";
|
||||
export const HOME_ID = 3245146787;
|
||||
|
||||
// Node IDs are carried in the `zwave_js` device identifiers, which is where the
|
||||
// panels read them back from.
|
||||
export const CONTROLLER_NODE_ID = 1;
|
||||
export const HALLWAY_NODE_ID = 7;
|
||||
export const DIMMER_NODE_ID = 12;
|
||||
export const MOTION_NODE_ID = 15;
|
||||
export const LOCK_NODE_ID = 18;
|
||||
export const THERMOSTAT_NODE_ID = 20;
|
||||
export const SENSOR_NODE_ID = 23;
|
||||
export const OUTLET_NODE_ID = 31;
|
||||
|
||||
export const DEVICE_IDS_BY_NODE_ID: Record<number, string> = {
|
||||
[CONTROLLER_NODE_ID]: "zwave-controller",
|
||||
[HALLWAY_NODE_ID]: "zwave-hallway-switch",
|
||||
[DIMMER_NODE_ID]: "zwave-dining-dimmer",
|
||||
[MOTION_NODE_ID]: "zwave-garage-motion",
|
||||
[LOCK_NODE_ID]: "zwave-back-door-lock",
|
||||
[THERMOSTAT_NODE_ID]: "zwave-bedroom-thermostat",
|
||||
[SENSOR_NODE_ID]: "zwave-basement-sensor",
|
||||
[OUTLET_NODE_ID]: "zwave-porch-outlet",
|
||||
};
|
||||
|
||||
const identifiers = (nodeId: number): [string, string][] => [
|
||||
["zwave_js", `${HOME_ID}-${nodeId}`],
|
||||
];
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"zwave-controller",
|
||||
"Z-Wave stick",
|
||||
"Zooz",
|
||||
"800 Series Z-Wave Long Range",
|
||||
ENTRY_ID,
|
||||
{ sw_version: "1.10", identifiers: identifiers(CONTROLLER_NODE_ID) }
|
||||
),
|
||||
device(
|
||||
"zwave-dining-dimmer",
|
||||
"Dining room dimmer",
|
||||
"Inovelli",
|
||||
"LZW31-SN",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(DIMMER_NODE_ID) }
|
||||
),
|
||||
device("zwave-back-door-lock", "Back door lock", "Yale", "YRD226", ENTRY_ID, {
|
||||
identifiers: identifiers(LOCK_NODE_ID),
|
||||
}),
|
||||
device(
|
||||
"zwave-basement-sensor",
|
||||
"Basement sensor",
|
||||
"Aeotec",
|
||||
"ZWA005 TriSensor",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(SENSOR_NODE_ID) }
|
||||
),
|
||||
device("zwave-hallway-switch", "Hallway switch", "Zooz", "ZEN76", ENTRY_ID, {
|
||||
area_id: "entrance",
|
||||
identifiers: identifiers(HALLWAY_NODE_ID),
|
||||
}),
|
||||
device(
|
||||
"zwave-garage-motion",
|
||||
"Garage motion",
|
||||
"Aeotec",
|
||||
"MultiSensor 7",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(MOTION_NODE_ID) }
|
||||
),
|
||||
device(
|
||||
"zwave-bedroom-thermostat",
|
||||
"Bedroom thermostat",
|
||||
"Honeywell",
|
||||
"T6 Pro",
|
||||
ENTRY_ID,
|
||||
{ area_id: "bedroom", identifiers: identifiers(THERMOSTAT_NODE_ID) }
|
||||
),
|
||||
device("zwave-porch-outlet", "Porch outlet", "Zooz", "ZEN15", ENTRY_ID, {
|
||||
identifiers: identifiers(OUTLET_NODE_ID),
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"light.dining_room",
|
||||
"zwave-dining-dimmer",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry("lock.back_door", "zwave-back-door-lock", ENTRY_ID, "zwave_js"),
|
||||
registryEntry(
|
||||
"sensor.basement_humidity",
|
||||
"zwave-basement-sensor",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry("light.hallway", "zwave-hallway-switch", ENTRY_ID, "zwave_js"),
|
||||
registryEntry(
|
||||
"binary_sensor.garage_motion",
|
||||
"zwave-garage-motion",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry(
|
||||
"climate.bedroom",
|
||||
"zwave-bedroom-thermostat",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry(
|
||||
"switch.porch_outlet",
|
||||
"zwave-porch-outlet",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
];
|
||||
|
||||
export const zwaveJsFixtures: ConnectivityFixtures = {
|
||||
components: ["zwave_js"],
|
||||
commands: ["zwave_js/"],
|
||||
manifests: [manifest("zwave_js", "Z-Wave", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "zwave_js", "Z-Wave", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.dining_room": {
|
||||
entity_id: "light.dining_room",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Dining room dimmer",
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 128,
|
||||
},
|
||||
},
|
||||
"lock.back_door": {
|
||||
entity_id: "lock.back_door",
|
||||
state: "unlocked",
|
||||
attributes: { friendly_name: "Back door lock" },
|
||||
},
|
||||
"sensor.basement_humidity": {
|
||||
entity_id: "sensor.basement_humidity",
|
||||
state: "58",
|
||||
attributes: {
|
||||
friendly_name: "Basement humidity",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"light.hallway": {
|
||||
entity_id: "light.hallway",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Hallway switch",
|
||||
supported_color_modes: ["onoff"],
|
||||
},
|
||||
},
|
||||
"binary_sensor.garage_motion": {
|
||||
entity_id: "binary_sensor.garage_motion",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Garage motion", device_class: "motion" },
|
||||
},
|
||||
"climate.bedroom": {
|
||||
entity_id: "climate.bedroom",
|
||||
state: "heat",
|
||||
attributes: {
|
||||
friendly_name: "Bedroom thermostat",
|
||||
hvac_modes: ["off", "heat"],
|
||||
current_temperature: 19.6,
|
||||
temperature: 20.5,
|
||||
min_temp: 7,
|
||||
max_temp: 30,
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
"switch.porch_outlet": {
|
||||
entity_id: "switch.porch_outlet",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Porch outlet" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import type {
|
||||
ZWaveJSController,
|
||||
ZWaveJSNetwork,
|
||||
ZWaveJSNodeStatisticsUpdatedMessage,
|
||||
ZWaveJSNodeStatus,
|
||||
ZwaveJSProvisioningEntry,
|
||||
} from "../../../../../src/data/zwave_js";
|
||||
import {
|
||||
NodeStatus,
|
||||
ProvisioningEntryStatus,
|
||||
SecurityClass,
|
||||
} from "../../../../../src/data/zwave_js";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
import {
|
||||
CONTROLLER_NODE_ID,
|
||||
DEVICE_IDS_BY_NODE_ID,
|
||||
DIMMER_NODE_ID,
|
||||
HALLWAY_NODE_ID,
|
||||
HOME_ID,
|
||||
LOCK_NODE_ID,
|
||||
MOTION_NODE_ID,
|
||||
OUTLET_NODE_ID,
|
||||
SENSOR_NODE_ID,
|
||||
THERMOSTAT_NODE_ID,
|
||||
} from "./fixtures";
|
||||
|
||||
const node = (
|
||||
nodeId: number,
|
||||
status: NodeStatus,
|
||||
overrides: Partial<ZWaveJSNodeStatus> = {}
|
||||
): ZWaveJSNodeStatus => ({
|
||||
node_id: nodeId,
|
||||
ready: true,
|
||||
status,
|
||||
is_secure: true,
|
||||
is_routing: true,
|
||||
zwave_plus_version: 2,
|
||||
highest_security_class: SecurityClass.S2_Authenticated,
|
||||
is_controller_node: false,
|
||||
has_firmware_update_cc: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const NODES: ZWaveJSNodeStatus[] = [
|
||||
node(CONTROLLER_NODE_ID, NodeStatus.Alive, {
|
||||
is_controller_node: true,
|
||||
highest_security_class: SecurityClass.S2_AccessControl,
|
||||
}),
|
||||
node(HALLWAY_NODE_ID, NodeStatus.Alive),
|
||||
node(DIMMER_NODE_ID, NodeStatus.Alive),
|
||||
node(MOTION_NODE_ID, NodeStatus.Asleep, { is_routing: false }),
|
||||
node(LOCK_NODE_ID, NodeStatus.Asleep, {
|
||||
is_routing: false,
|
||||
highest_security_class: SecurityClass.S2_AccessControl,
|
||||
}),
|
||||
node(THERMOSTAT_NODE_ID, NodeStatus.Alive),
|
||||
node(SENSOR_NODE_ID, NodeStatus.Dead, { is_routing: false }),
|
||||
node(OUTLET_NODE_ID, NodeStatus.Alive),
|
||||
];
|
||||
|
||||
const CONTROLLER: ZWaveJSController = {
|
||||
home_id: HOME_ID,
|
||||
sdk_version: "7.19.3",
|
||||
type: 1,
|
||||
own_node_id: CONTROLLER_NODE_ID,
|
||||
rf_region: null,
|
||||
is_primary: true,
|
||||
is_using_home_id_from_other_network: false,
|
||||
is_sis_present: true,
|
||||
was_real_primary: true,
|
||||
is_suc: true,
|
||||
// NodeType.Controller; the enum itself is not exported from data/zwave_js.
|
||||
node_type: 0 as ZWaveJSController["node_type"],
|
||||
firmware_version: "1.10",
|
||||
manufacturer_id: 634,
|
||||
product_id: 4,
|
||||
product_type: 3,
|
||||
supported_function_types: [],
|
||||
suc_node_id: CONTROLLER_NODE_ID,
|
||||
supports_timers: false,
|
||||
is_rebuilding_routes: false,
|
||||
// InclusionState.Idle
|
||||
inclusion_state: 0,
|
||||
nodes: NODES,
|
||||
supports_long_range: true,
|
||||
};
|
||||
|
||||
const NETWORK: ZWaveJSNetwork = {
|
||||
client: {
|
||||
state: "connected",
|
||||
ws_server_url: "ws://localhost:3000",
|
||||
server_version: "1.40.1",
|
||||
driver_version: "13.2.0",
|
||||
},
|
||||
controller: CONTROLLER,
|
||||
};
|
||||
|
||||
const PROVISIONING_ENTRIES: ZwaveJSProvisioningEntry[] = [
|
||||
{
|
||||
dsk: "51590-27189-49239-34778-15304-59293-52843-45852",
|
||||
securityClasses: [SecurityClass.S2_Authenticated],
|
||||
status: ProvisioningEntryStatus.Active,
|
||||
additionalProperties: {},
|
||||
manufacturer: "Zooz",
|
||||
label: "ZEN32 Scene Controller",
|
||||
},
|
||||
];
|
||||
|
||||
// Node IDs each node can reach directly. Only requested when the map's
|
||||
// neighbor overlay is toggled on.
|
||||
const NEIGHBORS: Record<number, number[]> = {
|
||||
[CONTROLLER_NODE_ID]: [HALLWAY_NODE_ID, DIMMER_NODE_ID, OUTLET_NODE_ID],
|
||||
[HALLWAY_NODE_ID]: [
|
||||
CONTROLLER_NODE_ID,
|
||||
DIMMER_NODE_ID,
|
||||
LOCK_NODE_ID,
|
||||
THERMOSTAT_NODE_ID,
|
||||
],
|
||||
[DIMMER_NODE_ID]: [
|
||||
CONTROLLER_NODE_ID,
|
||||
HALLWAY_NODE_ID,
|
||||
SENSOR_NODE_ID,
|
||||
OUTLET_NODE_ID,
|
||||
],
|
||||
[MOTION_NODE_ID]: [THERMOSTAT_NODE_ID],
|
||||
[LOCK_NODE_ID]: [HALLWAY_NODE_ID],
|
||||
[THERMOSTAT_NODE_ID]: [HALLWAY_NODE_ID, MOTION_NODE_ID],
|
||||
[SENSOR_NODE_ID]: [DIMMER_NODE_ID],
|
||||
[OUTLET_NODE_ID]: [CONTROLLER_NODE_ID, DIMMER_NODE_ID],
|
||||
};
|
||||
|
||||
// Route each node reports as its last working route back to the controller,
|
||||
// so the map can draw the mesh instead of a star.
|
||||
const ROUTES: Record<number, { repeaters: number[]; rssi: number }> = {
|
||||
[HALLWAY_NODE_ID]: { repeaters: [], rssi: -44 },
|
||||
[DIMMER_NODE_ID]: { repeaters: [], rssi: -48 },
|
||||
[OUTLET_NODE_ID]: { repeaters: [], rssi: -57 },
|
||||
[LOCK_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -72 },
|
||||
[THERMOSTAT_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -66 },
|
||||
[MOTION_NODE_ID]: {
|
||||
repeaters: [HALLWAY_NODE_ID, THERMOSTAT_NODE_ID],
|
||||
rssi: -79,
|
||||
},
|
||||
[SENSOR_NODE_ID]: { repeaters: [DIMMER_NODE_ID], rssi: -81 },
|
||||
};
|
||||
|
||||
const NODE_IDS_BY_DEVICE_ID: Record<string, number> = Object.fromEntries(
|
||||
Object.entries(DEVICE_IDS_BY_NODE_ID).map(([nodeId, deviceId]) => [
|
||||
deviceId,
|
||||
Number(nodeId),
|
||||
])
|
||||
);
|
||||
|
||||
const buildNodeStatistics = (
|
||||
nodeId: number
|
||||
): ZWaveJSNodeStatisticsUpdatedMessage => {
|
||||
const route = ROUTES[nodeId];
|
||||
return {
|
||||
event: "statistics updated",
|
||||
source: "node",
|
||||
nodeId,
|
||||
node_id: nodeId,
|
||||
commands_tx: 1200 + nodeId * 7,
|
||||
commands_rx: 980 + nodeId * 5,
|
||||
commands_dropped_tx: 0,
|
||||
commands_dropped_rx: nodeId === SENSOR_NODE_ID ? 4 : 0,
|
||||
timeout_response: 0,
|
||||
rtt: 24 + nodeId,
|
||||
rssi: route?.rssi ?? null,
|
||||
lwr: route
|
||||
? {
|
||||
protocol_data_rate: 3,
|
||||
repeaters: route.repeaters.map(
|
||||
(repeaterNodeId) => DEVICE_IDS_BY_NODE_ID[repeaterNodeId]
|
||||
),
|
||||
rssi: route.rssi,
|
||||
repeater_rssi: route.repeaters.map(() => -55),
|
||||
route_failed_between: null,
|
||||
}
|
||||
: null,
|
||||
nlwr: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockZwaveJs = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zwave_js/network_status", () => NETWORK);
|
||||
hass.mockWS("zwave_js/network_neighbors", () => NEIGHBORS);
|
||||
hass.mockWS("zwave_js/get_provisioning_entries", () => PROVISIONING_ENTRIES);
|
||||
hass.mockWS("zwave_js/data_collection_status", () => ({
|
||||
enabled: false,
|
||||
opted_in: false,
|
||||
}));
|
||||
hass.mockWS("zwave_js/subscribe_s2_inclusion", () => () => undefined);
|
||||
hass.mockWS("zwave_js/node_status", (msg: { device_id: string }) => {
|
||||
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
|
||||
return NODES.find((n) => n.node_id === nodeId) ?? NODES[0];
|
||||
});
|
||||
hass.mockWS(
|
||||
"zwave_js/subscribe_node_statistics",
|
||||
(msg: { device_id: string }, _hass, onChange) => {
|
||||
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
|
||||
if (nodeId === undefined || nodeId === CONTROLLER_NODE_ID) {
|
||||
return () => undefined;
|
||||
}
|
||||
return emitInitial(() => onChange?.(buildNodeStatistics(nodeId)));
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"zwave_js/subscribe_controller_statistics",
|
||||
(_msg, _hass, onChange) =>
|
||||
emitInitial(() =>
|
||||
onChange?.({
|
||||
event: "statistics updated",
|
||||
source: "controller",
|
||||
messages_tx: 18234,
|
||||
messages_rx: 17980,
|
||||
messages_dropped_tx: 2,
|
||||
messages_dropped_rx: 5,
|
||||
nak: 0,
|
||||
can: 3,
|
||||
timeout_ack: 1,
|
||||
timeout_response: 0,
|
||||
timeout_callback: 0,
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import { connectivityDevices } from "./connectivity/fixtures";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
@@ -86,4 +87,5 @@ export const demoDevices: DeviceRegistryEntry[] = [
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
...connectivityDevices,
|
||||
];
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
import { connectivityManifests } from "./connectivity/fixtures";
|
||||
import { manifest } from "./manifest";
|
||||
|
||||
const manifests: IntegrationManifest[] = [
|
||||
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
|
||||
@@ -62,11 +50,18 @@ const manifests: IntegrationManifest[] = [
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
...connectivityManifests,
|
||||
];
|
||||
|
||||
export const mockIntegration = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("manifest/list", () => manifests);
|
||||
hass.mockWS("manifest/get", (msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration)
|
||||
// Never answer with undefined: the integration page reads the manifest it
|
||||
// gets back without guarding, so an unlisted domain would throw. The real
|
||||
// backend always has a manifest for a domain that has config entries.
|
||||
hass.mockWS(
|
||||
"manifest/get",
|
||||
(msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration) ??
|
||||
manifest(msg.integration, msg.integration)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
|
||||
/**
|
||||
* Builds a demo integration manifest. Lives in its own module so both the
|
||||
* manifest registry and the per-integration fixtures that feed it can use it
|
||||
* without importing each other.
|
||||
*/
|
||||
export const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
+54
-2
@@ -1,6 +1,58 @@
|
||||
import type { Tag } from "../../../src/data/tag";
|
||||
import type { Tag, UpdateTagParams } from "../../../src/data/tag";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockTags = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("tag/list", () => [{ id: "my-tag", name: "My Tag" }] as Tag[]);
|
||||
const tags: Tag[] = [{ id: "my-tag", name: "My Tag" }];
|
||||
let created = 0;
|
||||
|
||||
const find = (tagId: string) => tags.find((tag) => tag.id === tagId);
|
||||
|
||||
hass.mockWS("tag/list", () => tags.map((tag) => ({ ...tag })));
|
||||
|
||||
hass.mockWS(
|
||||
"tag/create",
|
||||
(msg: UpdateTagParams & { tag_id?: string }): Tag => {
|
||||
if (msg.tag_id && find(msg.tag_id)) {
|
||||
throw new Error(`Tag ${msg.tag_id} already exists`);
|
||||
}
|
||||
let id = msg.tag_id;
|
||||
while (!id) {
|
||||
created += 1;
|
||||
id = find(`tag-${created}`) ? undefined : `tag-${created}`;
|
||||
}
|
||||
const tag: Tag = {
|
||||
id,
|
||||
name: msg.name,
|
||||
description: msg.description,
|
||||
};
|
||||
tags.push(tag);
|
||||
return { ...tag };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"tag/update",
|
||||
(msg: UpdateTagParams & { tag_id: string }): Tag => {
|
||||
const tag = find(msg.tag_id);
|
||||
if (!tag) {
|
||||
throw new Error(`Tag ${msg.tag_id} not found`);
|
||||
}
|
||||
if ("name" in msg) {
|
||||
tag.name = msg.name;
|
||||
}
|
||||
if ("description" in msg) {
|
||||
tag.description = msg.description;
|
||||
}
|
||||
return { ...tag };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("tag/delete", (msg: { tag_id: string }) => {
|
||||
const index = tags.findIndex((tag) => tag.id === msg.tag_id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Tag ${msg.tag_id} not found`);
|
||||
}
|
||||
tags.splice(index, 1);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityBackendTranslations } from "./connectivity/fixtures";
|
||||
|
||||
export const mockTranslations = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("frontend/get_translations", (
|
||||
/* msg: {language: string, category: string} */
|
||||
) => ({ resources: {} }));
|
||||
export const mockTranslations = (
|
||||
hass: MockHomeAssistant,
|
||||
localizePromise?: Promise<LocalizeFunc>
|
||||
) => {
|
||||
hass.mockWS(
|
||||
"frontend/get_translations",
|
||||
(msg: { language: string; category?: string }) => ({
|
||||
resources:
|
||||
(msg.category && connectivityBackendTranslations[msg.category]) || {},
|
||||
})
|
||||
);
|
||||
|
||||
// `hass.loadBackendTranslation` is a no-op in the mocked hass, so categories
|
||||
// that are only requested through it never reach the WebSocket mock above.
|
||||
// Seed every category into the resources, after the fragment translations so
|
||||
// this merges on top of them.
|
||||
(localizePromise ?? Promise.resolve()).then(() =>
|
||||
hass.addTranslations(
|
||||
Object.assign({}, ...Object.values(connectivityBackendTranslations))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -693,9 +693,7 @@ class HaGallery extends LitElement {
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
user-select: initial;
|
||||
--ha-sidebar-width: 300px;
|
||||
--ha-sidebar-expanded-width: 300px;
|
||||
--ha-sidebar-expanded-item-width: 292px;
|
||||
|
||||
@@ -16,6 +16,25 @@ const ENTITIES = [
|
||||
duration: "0:05:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.active_timer",
|
||||
state: "active",
|
||||
attributes: {
|
||||
friendly_name: "Active timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:10:00",
|
||||
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.paused_timer",
|
||||
state: "paused",
|
||||
attributes: {
|
||||
friendly_name: "Paused timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:03:21",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-timer")
|
||||
|
||||
+23
-23
@@ -47,12 +47,12 @@
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.3",
|
||||
"@codemirror/view": "6.43.11",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.6.0",
|
||||
"@formatjs/intl-datetimeformat": "7.6.1",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
@@ -75,6 +75,8 @@
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.5.0",
|
||||
@@ -83,12 +85,11 @@
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.3.2",
|
||||
"@tsparticles/preset-links": "4.3.2",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
@@ -99,23 +100,22 @@
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.1",
|
||||
"hls.js": "1.7.2",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.3.0",
|
||||
"js-yaml": "5.4.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.10",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.11",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -147,13 +147,13 @@
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@lokalise/node-api": "16.4.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/core": "2.2.2",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -162,7 +162,6 @@
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.5",
|
||||
@@ -170,13 +169,14 @@
|
||||
"@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",
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.9.0",
|
||||
"eslint": "10.9.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -186,9 +186,9 @@
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.11.0",
|
||||
"globals": "17.12.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -198,12 +198,12 @@
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.3.0",
|
||||
"lint-staged": "17.4.1",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"minify-literals": "2.2.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
@@ -213,7 +213,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.11",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -231,6 +231,6 @@
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"volta": {
|
||||
"node": "24.19.0"
|
||||
"node": "24.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const ALLOWED_LICENSES = new Set([
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
|
||||
@@ -62,12 +62,16 @@ export function computeCssColor(color: string): string {
|
||||
/**
|
||||
* Get a color from document's styles
|
||||
* @param color - Named theme color (examples: `red`, `primary-text`)
|
||||
* @returns Resolved color; initial color if not found in document's styles
|
||||
* @param style - Styles to resolve against, defaults to the document body
|
||||
* @returns Resolved color; initial color if not found in the styles
|
||||
*/
|
||||
export function resolveThemeColor(color: string): string {
|
||||
export function resolveThemeColor(
|
||||
color: string,
|
||||
style?: CSSStyleDeclaration
|
||||
): string {
|
||||
const cssColor = computeCssVariableName(color);
|
||||
if (cssColor.startsWith("--")) {
|
||||
const resolved = getComputedStyle(document.body)
|
||||
const resolved = (style ?? getComputedStyle(document.body))
|
||||
.getPropertyValue(cssColor)
|
||||
.trim();
|
||||
return resolved || color;
|
||||
|
||||
+22
-5
@@ -1,4 +1,4 @@
|
||||
import { wcagLuminance, wcagContrast } from "culori";
|
||||
import { parse, wcagLuminance, wcagContrast } from "culori";
|
||||
import { theme2hex } from "./convert-color";
|
||||
|
||||
/**
|
||||
@@ -51,11 +51,28 @@ export const getRGBContrastRatio = (
|
||||
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Returns a contrasted color (black or white) based on the luminance of another color
|
||||
* Tells whether a color can be measured, which a CSS function that is passed
|
||||
* through unevaluated cannot, and whether it covers what is behind it
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to check
|
||||
* @returns Whether a contrast against this color says anything
|
||||
*/
|
||||
export const isOpaqueColor = (color: string): boolean => {
|
||||
const parsed = parse(color.trim());
|
||||
return parsed !== undefined && (parsed.alpha ?? 1) === 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a contrasted color (black or white) for another color
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
|
||||
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
|
||||
* @returns HEX color, whichever of black and white has the higher contrast ratio
|
||||
*/
|
||||
export const getContrastedColorHex = (color: string): string => {
|
||||
const lum = wcagLuminance(theme2hex(color));
|
||||
return lum > 0.5 ? "#000000" : "#ffffff";
|
||||
const hex = theme2hex(color.trim());
|
||||
// culori throws on a color it cannot read
|
||||
if (!parse(hex)) {
|
||||
return "#ffffff";
|
||||
}
|
||||
return wcagContrast(hex, "#000000") >= wcagContrast(hex, "#ffffff")
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
};
|
||||
|
||||
@@ -33,7 +33,6 @@ export const filterPanelStyles = css`
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
ha-expansion-panel::part(summary) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.content {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { HaSlider } from "../../components/ha-slider";
|
||||
import type { MediaPlayerEntity } from "../../data/media-player";
|
||||
import { formatMediaTime, getCurrentProgress } from "../../data/media-player";
|
||||
|
||||
const PENDING_SEEK_TIMEOUT_MS = 5000;
|
||||
const PENDING_SEEK_TOLERANCE_S = 2;
|
||||
const PROGRESS_INTERVAL_MS = 1000;
|
||||
|
||||
export interface MediaProgressControllerOptions {
|
||||
getStateObj: () => MediaPlayerEntity | undefined;
|
||||
getSlider: () => HaSlider | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a media progress slider: ticks it while the media plays, holds it
|
||||
* on the target after a seek until the player state catches up, and leaves
|
||||
* it alone while the user drags it. The controller owns the slider value;
|
||||
* the host must not bind `.value` and reads `progress` for position text.
|
||||
*/
|
||||
export class MediaProgressController implements ReactiveController {
|
||||
/** Current position in media seconds, pending seek included. */
|
||||
public progress?: number;
|
||||
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private _options: MediaProgressControllerOptions;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
private _pendingPosition?: number;
|
||||
|
||||
private _pendingSince = 0;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
options: MediaProgressControllerOptions
|
||||
) {
|
||||
this._host = host;
|
||||
this._options = options;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostUpdate(): void {
|
||||
this._computeProgress();
|
||||
}
|
||||
|
||||
public hostUpdated(): void {
|
||||
this._writeSlider();
|
||||
this._syncInterval();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._stopInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a seek so the displayed position moves to the target immediately
|
||||
* instead of jumping back until the player state reflects the seek.
|
||||
*/
|
||||
public seek(position: number): void {
|
||||
this._pendingPosition = position;
|
||||
this._pendingSince = Date.now();
|
||||
this._tick();
|
||||
}
|
||||
|
||||
private _tick(): void {
|
||||
this._computeProgress();
|
||||
this._writeSlider();
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
private _syncInterval(): void {
|
||||
const stateObj = this._options.getStateObj();
|
||||
if (
|
||||
stateObj?.state === "playing" &&
|
||||
stateObj.attributes.media_duration &&
|
||||
stateObj.attributes.media_position !== undefined
|
||||
) {
|
||||
if (!this._interval) {
|
||||
this._interval = window.setInterval(
|
||||
() => this._tick(),
|
||||
PROGRESS_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._stopInterval();
|
||||
}
|
||||
}
|
||||
|
||||
private _stopInterval(): void {
|
||||
if (this._interval) {
|
||||
clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeProgress(): void {
|
||||
const stateObj = this._options.getStateObj();
|
||||
if (
|
||||
!stateObj ||
|
||||
!stateObj.attributes.media_duration ||
|
||||
stateObj.attributes.media_position === undefined
|
||||
) {
|
||||
this.progress = undefined;
|
||||
return;
|
||||
}
|
||||
this.progress = this._applyPendingSeek(
|
||||
getCurrentProgress(stateObj),
|
||||
stateObj.state === "playing",
|
||||
stateObj.attributes.media_duration
|
||||
);
|
||||
}
|
||||
|
||||
private _applyPendingSeek(
|
||||
current: number,
|
||||
playing: boolean,
|
||||
duration: number
|
||||
): number {
|
||||
if (this._pendingPosition === undefined) {
|
||||
return current;
|
||||
}
|
||||
const elapsedMs = Date.now() - this._pendingSince;
|
||||
const target = Math.min(
|
||||
this._pendingPosition + (playing ? elapsedMs / 1000 : 0),
|
||||
duration
|
||||
);
|
||||
if (
|
||||
elapsedMs > PENDING_SEEK_TIMEOUT_MS ||
|
||||
Math.abs(current - target) <= PENDING_SEEK_TOLERANCE_S
|
||||
) {
|
||||
this._pendingPosition = undefined;
|
||||
return current;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private _writeSlider(): void {
|
||||
const slider = this._options.getSlider();
|
||||
if (!slider) {
|
||||
return;
|
||||
}
|
||||
slider.valueFormatter = formatMediaTime;
|
||||
if (slider.matches(":state(dragging)")) {
|
||||
return;
|
||||
}
|
||||
slider.value = this.progress ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { timerTimeRemaining } from "../../data/timer";
|
||||
|
||||
/**
|
||||
* Tracks the live remaining time of a timer entity. While the timer is
|
||||
* active, the host is re-rendered every second with an updated
|
||||
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
|
||||
*
|
||||
* The host must call `setStateObj` whenever its timer entity changes.
|
||||
*/
|
||||
export class TimerRemainingTimeController implements ReactiveController {
|
||||
public timeRemaining?: number;
|
||||
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public setStateObj(stateObj: HassEntity | undefined): void {
|
||||
this._stateObj = stateObj;
|
||||
this._startInterval();
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._startInterval();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._clearInterval();
|
||||
}
|
||||
|
||||
private _startInterval(): void {
|
||||
this._clearInterval();
|
||||
if (!this._stateObj) {
|
||||
this.timeRemaining = undefined;
|
||||
return;
|
||||
}
|
||||
this._calculateRemaining();
|
||||
|
||||
if (this._stateObj.state === "active") {
|
||||
this._interval = window.setInterval(() => {
|
||||
this._calculateRemaining();
|
||||
this._host.requestUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearInterval(): void {
|
||||
if (this._interval) {
|
||||
clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateRemaining(): void {
|
||||
this.timeRemaining = this._stateObj
|
||||
? timerTimeRemaining(this._stateObj)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
@@ -145,16 +145,11 @@ export const applyThemesOnElement = (
|
||||
element.__themes = { cacheKey, keys: newTheme?.keys };
|
||||
|
||||
// Set and/or reset styles
|
||||
if (window.ShadyCSS) {
|
||||
// Use ShadyCSS if available
|
||||
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
|
||||
} else {
|
||||
for (const s in styles) {
|
||||
if (s === null) {
|
||||
element.style.removeProperty(s);
|
||||
} else {
|
||||
element.style.setProperty(s, styles[s]);
|
||||
}
|
||||
for (const s in styles) {
|
||||
if (s === null) {
|
||||
element.style.removeProperty(s);
|
||||
} else {
|
||||
element.style.setProperty(s, styles[s]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import type { Map, TileLayer } from "leaflet";
|
||||
import type { Map } from "leaflet";
|
||||
import type { MapBaseLayer } from "../map/base-layer";
|
||||
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
|
||||
|
||||
// Sets up a Leaflet map on the provided DOM element
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletModuleType = typeof import("leaflet");
|
||||
|
||||
export interface LeafletMapSetup {
|
||||
map: Map;
|
||||
leaflet: LeafletModuleType;
|
||||
baseLayer: MapBaseLayer;
|
||||
}
|
||||
|
||||
export const setupLeafletMap = async (
|
||||
mapElement: HTMLElement,
|
||||
initialView?: { latitude: number; longitude: number; zoom?: number }
|
||||
): Promise<[Map, LeafletModuleType, TileLayer]> => {
|
||||
initialView?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
darkMode?: boolean;
|
||||
token?: string;
|
||||
}
|
||||
): Promise<LeafletMapSetup> => {
|
||||
if (!mapElement.parentNode) {
|
||||
throw new Error("Cannot setup Leaflet map on disconnected element");
|
||||
}
|
||||
@@ -17,7 +31,11 @@ export const setupLeafletMap = async (
|
||||
|
||||
await import("leaflet.markercluster");
|
||||
|
||||
const map = Leaflet.map(mapElement);
|
||||
const map = Leaflet.map(mapElement, {
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
});
|
||||
map.attributionControl.setPrefix("");
|
||||
const style = document.createElement("link");
|
||||
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
|
||||
style.setAttribute("rel", "stylesheet");
|
||||
@@ -38,21 +56,14 @@ export const setupLeafletMap = async (
|
||||
);
|
||||
}
|
||||
|
||||
const tileLayer = createTileLayer(Leaflet).addTo(map);
|
||||
|
||||
return [map, Leaflet, tileLayer];
|
||||
};
|
||||
|
||||
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
|
||||
leaflet.tileLayer(
|
||||
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
|
||||
leaflet.Browser.retina ? "@2x.png" : ".png"
|
||||
}`,
|
||||
{
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: "abcd",
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
}
|
||||
// The base layer adds itself: the vector layer only builds its MapLibre map
|
||||
// once it is on the map, and that failing has to fall back to raster.
|
||||
const baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
initialView?.darkMode ?? false,
|
||||
initialView?.token
|
||||
);
|
||||
|
||||
return { map, leaflet: Leaflet, baseLayer };
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Toggle Attribute Polyfill because it's too new for some browsers
|
||||
export const toggleAttribute = (
|
||||
el: HTMLElement,
|
||||
name: string,
|
||||
force?: boolean
|
||||
) => {
|
||||
if (force !== undefined) {
|
||||
force = !!force;
|
||||
}
|
||||
|
||||
if (el.hasAttribute(name)) {
|
||||
if (force) {
|
||||
return true;
|
||||
}
|
||||
|
||||
el.removeAttribute(name);
|
||||
return false;
|
||||
}
|
||||
if (force === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
el.setAttribute(name, "");
|
||||
return true;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const webComponentsSupported = "attachShadow" in Element.prototype;
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* ES5-compatible implementation of the keyed directive.
|
||||
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
|
||||
*
|
||||
* This implementation avoids parameter destructuring in the update() method,
|
||||
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
|
||||
*
|
||||
* Used only for ES5 builds (legacy browsers). Modern builds use the original
|
||||
* lit-html keyed directive.
|
||||
*
|
||||
* @see https://github.com/home-assistant/frontend/issues/28732
|
||||
*/
|
||||
import { directive, Directive } from "lit-html/directive.js";
|
||||
import { setCommittedValue } from "lit-html/directive-helpers.js";
|
||||
// eslint-disable-next-line lit/no-legacy-imports
|
||||
import { nothing } from "lit-html";
|
||||
import type { Part } from "lit-html/directive.js";
|
||||
|
||||
class KeyedES5 extends Directive {
|
||||
private _key: unknown = nothing;
|
||||
|
||||
render(k: unknown, v: unknown) {
|
||||
this._key = k;
|
||||
return v;
|
||||
}
|
||||
|
||||
update(part: unknown, args: [unknown, unknown]) {
|
||||
const k = args[0];
|
||||
const v = args[1];
|
||||
if (k !== this._key) {
|
||||
// Clear the part before returning a value. The one-arg form of
|
||||
// setCommittedValue sets the value to a sentinel which forces a
|
||||
// commit the next render.
|
||||
setCommittedValue(part as Part);
|
||||
this._key = k;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a renderable value with a unique key. When the key changes, the
|
||||
* previous DOM is removed and disposed before rendering the next value, even
|
||||
* if the value - such as a template - is the same.
|
||||
*
|
||||
* This is useful for forcing re-renders of stateful components, or working
|
||||
* with code that expects new data to generate new HTML elements, such as some
|
||||
* animation techniques.
|
||||
*/
|
||||
export const keyed = directive(KeyedES5);
|
||||
@@ -0,0 +1,333 @@
|
||||
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
|
||||
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
|
||||
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
|
||||
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
|
||||
import {
|
||||
MAP_TILES_PATH,
|
||||
mapTilesUrl,
|
||||
refreshMapTilesToken,
|
||||
subscribeMapTilesToken,
|
||||
withMapTilesToken,
|
||||
} from "../../data/map_tiles";
|
||||
|
||||
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
|
||||
// TileJSON, deliberately: it follows whoever serves the tiles.
|
||||
export const VECTOR_STYLES = {
|
||||
light: "/static/map/light.json",
|
||||
dark: "/static/map/dark.json",
|
||||
} as const;
|
||||
|
||||
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
|
||||
// worker, hence a URL rather than an import.
|
||||
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
|
||||
|
||||
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
|
||||
// OSM serves no @2x variant.
|
||||
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
|
||||
// The demo has no proxy to go through. Upstream serves raster to a browser that
|
||||
// identifies itself with a referrer, which the demo page's `same-origin` meta
|
||||
// policy strips again unless the tiles ask for it back.
|
||||
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
const OSM_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
|
||||
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
|
||||
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
|
||||
export const CONTEXT_RESTORE_GRACE = 2000;
|
||||
export const RECOVERY_THROTTLE = 30000;
|
||||
|
||||
// On the map, not the layer: marker clustering throws without a maximum. The
|
||||
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
|
||||
export const MAP_MIN_ZOOM = 1;
|
||||
export const MAP_MAX_ZOOM = 20;
|
||||
// OSM's raster stops at 19 and the proxy refuses higher, so Leaflet scales the
|
||||
// last level up rather than asking for tiles that are not there.
|
||||
const RASTER_MAX_NATIVE_ZOOM = 19;
|
||||
|
||||
// Leaflet substitutes any option into the URL template; its types do not.
|
||||
type TokenTileLayerOptions = TileLayerOptions & { token?: string };
|
||||
|
||||
export interface MapBaseLayer {
|
||||
// A no-op for raster, which has no dark variant and is inverted in CSS.
|
||||
setDarkMode: (darkMode: boolean) => void;
|
||||
}
|
||||
|
||||
let webGL2Supported: boolean | undefined;
|
||||
|
||||
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
|
||||
export const supportsWebGL2 = (): boolean => {
|
||||
if (webGL2Supported === undefined) {
|
||||
try {
|
||||
const context = document.createElement("canvas").getContext("webgl2");
|
||||
webGL2Supported = Boolean(context);
|
||||
// Contexts are scarce; the probe must not keep one.
|
||||
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
} catch {
|
||||
webGL2Supported = false;
|
||||
}
|
||||
}
|
||||
return webGL2Supported;
|
||||
};
|
||||
|
||||
// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would
|
||||
// mangle its {fontstack} and {range} placeholders.
|
||||
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
|
||||
// its glyphs and sprites, which is why those come from VersaTiles.
|
||||
const DEMO_UPSTREAM = {
|
||||
tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json",
|
||||
assets: "https://tiles.versatiles.org/assets",
|
||||
};
|
||||
|
||||
const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
|
||||
style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`;
|
||||
style.sprite = [
|
||||
{ id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` },
|
||||
];
|
||||
Object.values(style.sources).forEach((source) => {
|
||||
if ("url" in source) {
|
||||
source.url = DEMO_UPSTREAM.tilejson;
|
||||
}
|
||||
});
|
||||
return style;
|
||||
};
|
||||
|
||||
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
const style: StyleSpecification = await (await fetch(url)).json();
|
||||
|
||||
if (__DEMO__) {
|
||||
return useDemoUpstream(style);
|
||||
}
|
||||
|
||||
if (typeof style.sprite === "string") {
|
||||
style.sprite = mapTilesUrl(style.sprite);
|
||||
} else if (Array.isArray(style.sprite)) {
|
||||
style.sprite = style.sprite.map((sprite) => ({
|
||||
...sprite,
|
||||
url: mapTilesUrl(sprite.url),
|
||||
}));
|
||||
}
|
||||
return style;
|
||||
};
|
||||
|
||||
// Global to MapLibre, and it throws when set twice.
|
||||
let rtlTextPluginRequested = false;
|
||||
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
if (rtlTextPluginRequested) {
|
||||
return;
|
||||
}
|
||||
rtlTextPluginRequested = true;
|
||||
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
|
||||
() => {
|
||||
// RTL labels stay reversed; everything else still renders.
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createVectorLayer = async (
|
||||
createLayer: typeof maplibreGL,
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean,
|
||||
token: string | undefined
|
||||
): Promise<MapBaseLayer | undefined> => {
|
||||
let layer: ReturnType<typeof maplibreGL> | undefined;
|
||||
|
||||
try {
|
||||
layer = createLayer({
|
||||
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
|
||||
// Absolute, or the worker fetching tiles cannot resolve them.
|
||||
transformRequest: (url) => ({
|
||||
url: withMapTilesToken(url),
|
||||
// OSM asks a website for a referrer, and the demo has no instance
|
||||
// hostname to leak.
|
||||
referrerPolicy: __DEMO__ ? "origin" : undefined,
|
||||
}),
|
||||
});
|
||||
// The plugin builds the MapLibre map in `onAdd`, so a refused context or a
|
||||
// blocked worker throws here. Keep it guarded or those lose the fallback.
|
||||
layer.addTo(map);
|
||||
} catch {
|
||||
if (layer) {
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
// May never have finished being added.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Tracked apart so a failed request rolls back to what is displayed, not to
|
||||
// the opposite of what it asked - which with several in flight differs.
|
||||
let appliedDarkMode = darkMode;
|
||||
let requestedDarkMode = darkMode;
|
||||
// Styles are fetched, so only the newest request may touch the map.
|
||||
let latestRequest = 0;
|
||||
let vector = true;
|
||||
let refused = false;
|
||||
|
||||
const glMap = layer.getMaplibreMap();
|
||||
let fallbackTimeout: number | undefined;
|
||||
let contextLost = false;
|
||||
|
||||
// Declared first, but only ever called once all three exist.
|
||||
const handleVisibilityChange = () => {
|
||||
if (contextLost) {
|
||||
scheduleSwap();
|
||||
}
|
||||
};
|
||||
|
||||
const swapToRaster = () => {
|
||||
vector = false;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
// Nothing left to detach.
|
||||
}
|
||||
createRasterLayer(leaflet, map, token);
|
||||
};
|
||||
|
||||
const scheduleSwap = () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
// Backgrounding drops it too, and there it comes back on return.
|
||||
if (!vector || document.hidden) {
|
||||
return;
|
||||
}
|
||||
fallbackTimeout = window.setTimeout(swapToRaster, CONTEXT_RESTORE_GRACE);
|
||||
};
|
||||
|
||||
glMap.on("webglcontextlost", () => {
|
||||
contextLost = true;
|
||||
scheduleSwap();
|
||||
});
|
||||
glMap.on("webglcontextrestored", () => {
|
||||
contextLost = false;
|
||||
clearTimeout(fallbackTimeout);
|
||||
});
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
map.on("unload", () => {
|
||||
// Otherwise the timer revives a map that is already gone.
|
||||
clearTimeout(fallbackTimeout);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
|
||||
const applyStyle = (newDarkMode: boolean) => {
|
||||
const request = ++latestRequest;
|
||||
|
||||
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
|
||||
.then((style) => {
|
||||
if (request === latestRequest) {
|
||||
appliedDarkMode = newDarkMode;
|
||||
layer.getMaplibreMap()?.setStyle(style);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (request === latestRequest) {
|
||||
requestedDarkMode = appliedDarkMode;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// A refused request leaves the source dead: the TileJSON is fetched once and
|
||||
// is never retried, so the style has to be applied again once there is a new
|
||||
// token. Throttled, or a proxy refusing for another reason loops.
|
||||
let lastRecovery = 0;
|
||||
glMap.on("error", (event) => {
|
||||
const status = (event.error as { status?: number } | undefined)?.status;
|
||||
// 403 is a stale token, 404 the proxy not registered yet during a restart,
|
||||
// and no status at all a network failure. All three recover the same way,
|
||||
// and a token that comes back unchanged costs nothing.
|
||||
if (status !== undefined && status !== 403 && status !== 404) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
lastRecovery = Date.now();
|
||||
refused = true;
|
||||
refreshMapTilesToken();
|
||||
});
|
||||
|
||||
// Only a new token clears the refusal. A theme change in between applies a
|
||||
// style that is refused just as the last one was, so it proves nothing.
|
||||
const unsubscribeToken = subscribeMapTilesToken(() => {
|
||||
if (vector && refused) {
|
||||
refused = false;
|
||||
applyStyle(requestedDarkMode);
|
||||
}
|
||||
});
|
||||
map.on("unload", unsubscribeToken);
|
||||
|
||||
return {
|
||||
setDarkMode: (newDarkMode: boolean) => {
|
||||
if (!vector || newDarkMode === requestedDarkMode) {
|
||||
return;
|
||||
}
|
||||
requestedDarkMode = newDarkMode;
|
||||
applyStyle(newDarkMode);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createRasterLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
token: string | undefined
|
||||
): MapBaseLayer => {
|
||||
const layer = leaflet
|
||||
.tileLayer(__DEMO__ ? DEMO_RASTER_TILE_URL : mapTilesUrl(RASTER_TILE_URL), {
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
maxNativeZoom: RASTER_MAX_NATIVE_ZOOM,
|
||||
referrerPolicy: __DEMO__ ? "origin" : undefined,
|
||||
// Leaflet throws on an undefined template variable, so no token means an
|
||||
// empty one: the tiles 403 and the markers still draw.
|
||||
token: token ?? "",
|
||||
} as TokenTileLayerOptions)
|
||||
.addTo(map);
|
||||
|
||||
// Substituted per request, so a refreshed token needs no new layer.
|
||||
const unsubscribe = subscribeMapTilesToken((newToken) => {
|
||||
(layer.options as TokenTileLayerOptions).token = newToken;
|
||||
// Tiles that 403'd are cached as failures; only a redraw asks again.
|
||||
layer.redraw();
|
||||
});
|
||||
map.on("unload", unsubscribe);
|
||||
|
||||
return { setDarkMode: () => undefined };
|
||||
};
|
||||
|
||||
export const createBaseLayer = async (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean,
|
||||
token: string | undefined,
|
||||
// Skip the vector layer, e.g. after a permanent WebGL context loss
|
||||
rasterOnly = false
|
||||
): Promise<MapBaseLayer> => {
|
||||
if (!rasterOnly && supportsWebGL2()) {
|
||||
let vectorLayer: MapBaseLayer | undefined;
|
||||
try {
|
||||
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
|
||||
import("@maplibre/maplibre-gl-leaflet"),
|
||||
import("maplibre-gl"),
|
||||
]);
|
||||
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
|
||||
vectorLayer = await createVectorLayer(
|
||||
createLayer,
|
||||
leaflet,
|
||||
map,
|
||||
darkMode,
|
||||
token
|
||||
);
|
||||
} catch {
|
||||
// No chunk, no vector map - but still a map.
|
||||
}
|
||||
if (vectorLayer) {
|
||||
return vectorLayer;
|
||||
}
|
||||
}
|
||||
return createRasterLayer(leaflet, map, token);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
|
||||
|
||||
/** Hit target for the radius handle; comfortably above touch minimums */
|
||||
export const RESIZE_HANDLE_SIZE = 24;
|
||||
|
||||
/** The visible dot inside the hit target */
|
||||
export const RESIZE_HANDLE_DOT_SIZE = 12;
|
||||
|
||||
/** Relative radius change per arrow key press on the handle */
|
||||
export const RESIZE_KEY_STEP = 0.1;
|
||||
|
||||
export const createResizeHandleElement = (label?: string): HTMLElement => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "editable-circle-resize";
|
||||
element.tabIndex = 0;
|
||||
element.setAttribute("role", "slider");
|
||||
element.setAttribute("aria-valuemin", "1");
|
||||
// A slider needs a maximum; no zone comes near 100 km
|
||||
element.setAttribute("aria-valuemax", "100000");
|
||||
if (label) {
|
||||
element.setAttribute("aria-label", label);
|
||||
}
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "editable-circle-resize-dot";
|
||||
element.appendChild(dot);
|
||||
return element;
|
||||
};
|
||||
|
||||
/** Styles for the handles, included by ha-map for both engines */
|
||||
export const editableCircleStyles = `
|
||||
.editable-circle-center {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
border: 2px solid var(--card-background-color, #fff);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
cursor: move;
|
||||
}
|
||||
.editable-circle-resize {
|
||||
width: ${RESIZE_HANDLE_SIZE}px;
|
||||
height: ${RESIZE_HANDLE_SIZE}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.editable-circle-resize-dot {
|
||||
width: ${RESIZE_HANDLE_DOT_SIZE}px;
|
||||
height: ${RESIZE_HANDLE_DOT_SIZE}px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-background-color, #fff);
|
||||
border: 2px solid var(--primary-color);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,339 @@
|
||||
import type {
|
||||
CircleMarker,
|
||||
Control,
|
||||
Map,
|
||||
MarkerClusterGroup,
|
||||
Polyline,
|
||||
} from "leaflet";
|
||||
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
|
||||
import type { MapBaseLayer } from "../base-layer";
|
||||
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
|
||||
import { DecoratedMarker } from "../decorated_marker";
|
||||
import { isTouch } from "../../../util/is_touch";
|
||||
import type {
|
||||
MapClusterOptions,
|
||||
MapCircleOptions,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapEngineOptions,
|
||||
MapFitOptions,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
MapMarkerOptions,
|
||||
MapPath,
|
||||
} from "../map-engine";
|
||||
|
||||
/** A leaflet marker that knows the engine handle it was created for */
|
||||
interface HandledMarker extends DecoratedMarker {
|
||||
engineHandle?: LeafletMarkerHandle;
|
||||
}
|
||||
|
||||
interface LeafletMarkerHandle extends MapMarkerHandle {
|
||||
marker: HandledMarker;
|
||||
}
|
||||
|
||||
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
|
||||
export class LeafletMapEngine implements MapEngine {
|
||||
/** For the ha-map jsdom tests only */
|
||||
public leafletMap?: Map;
|
||||
|
||||
public Leaflet?: LeafletModuleType;
|
||||
|
||||
private _baseLayer?: MapBaseLayer;
|
||||
|
||||
private _clusterable: HandledMarker[] = [];
|
||||
|
||||
private _cluster?: MarkerClusterGroup;
|
||||
|
||||
private _clusterOptions: MapClusterOptions | null = null;
|
||||
|
||||
private _scaleControl?: Control.Scale;
|
||||
|
||||
public async init(
|
||||
container: HTMLElement,
|
||||
options: MapEngineOptions
|
||||
): Promise<void> {
|
||||
const root = container.parentNode;
|
||||
if (!root) {
|
||||
throw new Error("Cannot set up a Leaflet map on a detached element");
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
|
||||
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
|
||||
await import("leaflet.markercluster");
|
||||
|
||||
const map = Leaflet.map(container, {
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
});
|
||||
map.attributionControl.setPrefix("");
|
||||
for (const href of [
|
||||
"/static/images/leaflet/leaflet.css",
|
||||
"/static/images/leaflet/MarkerCluster.css",
|
||||
]) {
|
||||
const style = document.createElement("link");
|
||||
style.setAttribute("href", href);
|
||||
style.setAttribute("rel", "stylesheet");
|
||||
root.appendChild(style);
|
||||
}
|
||||
map.setView(options.center, options.zoom);
|
||||
|
||||
// The base layer adds itself; a vector layer may still fall back to raster
|
||||
this._baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
options.darkMode,
|
||||
options.token,
|
||||
options.rasterOnly ?? false
|
||||
);
|
||||
this.leafletMap = map;
|
||||
this.Leaflet = Leaflet;
|
||||
map.zoomControl?.setPosition(options.zoomControlPosition);
|
||||
|
||||
const { events } = options;
|
||||
if (events.click) {
|
||||
map.on("click", (ev) => {
|
||||
events.click!([ev.latlng.lat, ev.latlng.lng]);
|
||||
});
|
||||
}
|
||||
if (events.zoomStart) {
|
||||
map.on("zoomstart", () => events.zoomStart!());
|
||||
}
|
||||
if (events.moveStart) {
|
||||
map.on("movestart", () => events.moveStart!());
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.leafletMap?.remove();
|
||||
this.leafletMap = undefined;
|
||||
this.Leaflet = undefined;
|
||||
this._baseLayer = undefined;
|
||||
this._cluster = undefined;
|
||||
this._clusterable = [];
|
||||
this._scaleControl = undefined;
|
||||
}
|
||||
|
||||
public invalidateSize(): void {
|
||||
this.leafletMap?.invalidateSize({ debounceMoveend: true });
|
||||
}
|
||||
|
||||
public hasUsableSize(): boolean {
|
||||
if (!this.leafletMap) {
|
||||
return false;
|
||||
}
|
||||
const size = this.leafletMap.getSize();
|
||||
if (size.x > 0 && size.y > 0) {
|
||||
return true;
|
||||
}
|
||||
const container = this.leafletMap.getContainer();
|
||||
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
||||
// The container was laid out since Leaflet last measured it
|
||||
this.leafletMap.invalidateSize(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public setDarkMode(darkMode: boolean): void {
|
||||
this._baseLayer?.setDarkMode(darkMode);
|
||||
}
|
||||
|
||||
public setZoomControlPosition(position: MapControlPosition): void {
|
||||
this.leafletMap?.zoomControl?.setPosition(position);
|
||||
}
|
||||
|
||||
public setScaleRuler(options: { metric: boolean } | null): void {
|
||||
if (this._scaleControl) {
|
||||
this.leafletMap?.removeControl(this._scaleControl);
|
||||
this._scaleControl = undefined;
|
||||
}
|
||||
if (!options || !this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
this._scaleControl = this.Leaflet.control.scale({
|
||||
position: "bottomleft",
|
||||
metric: options.metric,
|
||||
imperial: !options.metric,
|
||||
});
|
||||
this._scaleControl.addTo(this.leafletMap!);
|
||||
}
|
||||
|
||||
public setView(center: MapLatLng, zoom?: number): void {
|
||||
this.leafletMap?.setView(center, zoom);
|
||||
}
|
||||
|
||||
public setZoom(zoom: number): void {
|
||||
this.leafletMap?.setZoom(zoom);
|
||||
}
|
||||
|
||||
private _getZoom(): number {
|
||||
return this.leafletMap?.getZoom() ?? 0;
|
||||
}
|
||||
|
||||
private _project(location: MapLatLng): { x: number; y: number } {
|
||||
const point = this.leafletMap!.project(location, this._getZoom());
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
|
||||
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
|
||||
if (!this.leafletMap || !this.Leaflet || !points.length) {
|
||||
return;
|
||||
}
|
||||
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
|
||||
this.leafletMap.fitBounds(bounds, {
|
||||
maxZoom: options?.maxZoom,
|
||||
animate: options?.animate,
|
||||
});
|
||||
}
|
||||
|
||||
public panTo(location: MapLatLng): void {
|
||||
this.leafletMap?.panTo(location);
|
||||
}
|
||||
|
||||
public containsLocation(location: MapLatLng): boolean {
|
||||
return this.leafletMap?.getBounds().contains(location) ?? false;
|
||||
}
|
||||
|
||||
public addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapMarkerOptions
|
||||
): MapMarkerHandle {
|
||||
const decoration = options.decoration
|
||||
? this.Leaflet!.circle(location, {
|
||||
interactive: false,
|
||||
color: options.decoration.color,
|
||||
radius: options.decoration.radius,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
|
||||
icon: this.Leaflet!.divIcon({
|
||||
html: element,
|
||||
iconSize: options.size,
|
||||
iconAnchor: options.anchor,
|
||||
className: "",
|
||||
}),
|
||||
interactive: options.interactive ?? true,
|
||||
keyboard: options.interactive ?? true,
|
||||
title: options.title,
|
||||
});
|
||||
|
||||
const handle: LeafletMarkerHandle = {
|
||||
marker,
|
||||
location,
|
||||
clusterData: options.clusterData,
|
||||
remove: () => {
|
||||
this._cluster?.removeLayer(marker);
|
||||
marker.remove();
|
||||
const index = this._clusterable.indexOf(marker);
|
||||
if (index !== -1) {
|
||||
this._clusterable.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
marker.engineHandle = handle;
|
||||
|
||||
if (options.cluster) {
|
||||
// Placed on the map by the next setClustering call
|
||||
this._clusterable.push(marker);
|
||||
} else {
|
||||
marker.addTo(this.leafletMap!);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
public addCircle(
|
||||
center: MapLatLng,
|
||||
options: MapCircleOptions
|
||||
): MapItemHandle {
|
||||
const circle = this.Leaflet!.circle(center, {
|
||||
interactive: false,
|
||||
color: options.color,
|
||||
radius: options.radius,
|
||||
}).addTo(this.leafletMap!);
|
||||
return { remove: () => circle.remove() };
|
||||
}
|
||||
|
||||
public addPath(path: MapPath): MapItemHandle {
|
||||
const items: (Polyline | CircleMarker)[] = [];
|
||||
for (const segment of path.segments) {
|
||||
items.push(
|
||||
this.Leaflet!.polyline(segment.points, {
|
||||
color: path.color,
|
||||
opacity: segment.opacity,
|
||||
interactive: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const pathMarker of path.markers) {
|
||||
items.push(
|
||||
this.Leaflet!.circleMarker(pathMarker.location, {
|
||||
radius: isTouch ? 8 : 3,
|
||||
color: path.color,
|
||||
opacity: pathMarker.opacity,
|
||||
fillOpacity: pathMarker.opacity,
|
||||
interactive: true,
|
||||
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
|
||||
);
|
||||
}
|
||||
items.forEach((item) => item.addTo(this.leafletMap!));
|
||||
return { remove: () => items.forEach((item) => item.remove()) };
|
||||
}
|
||||
|
||||
public setClustering(options: MapClusterOptions | null): void {
|
||||
if (this._cluster) {
|
||||
this._cluster.remove();
|
||||
this._cluster = undefined;
|
||||
}
|
||||
this._clusterOptions = options;
|
||||
if (!this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
if (!options) {
|
||||
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
|
||||
return;
|
||||
}
|
||||
// markercluster groups by proximity only; groupKey is not supported here
|
||||
this._cluster = this.Leaflet.markerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
removeOutsideVisibleBounds: false,
|
||||
maxClusterRadius: options.radius,
|
||||
iconCreateFunction: (cluster) => {
|
||||
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
|
||||
(marker) => marker.engineHandle!
|
||||
);
|
||||
const latLng = cluster.getLatLng();
|
||||
const icon = this._clusterOptions!.iconBuilder(members, [
|
||||
latLng.lat,
|
||||
latLng.lng,
|
||||
]);
|
||||
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
|
||||
let anchor = icon.anchor;
|
||||
if (icon.location) {
|
||||
const clusterPoint = this._project([latLng.lat, latLng.lng]);
|
||||
const targetPoint = this._project(icon.location);
|
||||
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
|
||||
anchor = [
|
||||
base[0] - (targetPoint.x - clusterPoint.x),
|
||||
base[1] - (targetPoint.y - clusterPoint.y),
|
||||
];
|
||||
}
|
||||
return this.Leaflet!.divIcon({
|
||||
html: icon.element,
|
||||
iconSize: icon.size,
|
||||
iconAnchor: anchor,
|
||||
className: "",
|
||||
});
|
||||
},
|
||||
});
|
||||
this._cluster.addLayers(this._clusterable);
|
||||
this.leafletMap!.addLayer(this._cluster!);
|
||||
}
|
||||
|
||||
public refreshClusters(): void {
|
||||
this._cluster?.refreshClusters();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
|
||||
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
|
||||
* contract; new capabilities go on MapLibre only, as optional members like
|
||||
* `editing`.
|
||||
*
|
||||
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
|
||||
*/
|
||||
|
||||
export type MapLatLng = [latitude: number, longitude: number];
|
||||
|
||||
export type MapControlPosition =
|
||||
"topleft" | "topright" | "bottomleft" | "bottomright";
|
||||
|
||||
export interface MapEngineEvents {
|
||||
/** Click on the map surface, not on a marker */
|
||||
click(location: MapLatLng): void;
|
||||
/** Zoom is starting, programmatic or not */
|
||||
zoomStart(): void;
|
||||
/** The map starts moving, programmatic or not */
|
||||
moveStart(): void;
|
||||
/** The engine can no longer render; the host switches to the fallback */
|
||||
fatal(): void;
|
||||
}
|
||||
|
||||
export interface MapEngineOptions {
|
||||
center: MapLatLng;
|
||||
zoom: number;
|
||||
darkMode: boolean;
|
||||
/** Token for core's tile proxy */
|
||||
token?: string;
|
||||
zoomControlPosition: MapControlPosition;
|
||||
/** Render without WebGL after a permanent context loss; WebGL engines reject init */
|
||||
rasterOnly?: boolean;
|
||||
events: Partial<MapEngineEvents>;
|
||||
}
|
||||
|
||||
export interface MapFitOptions {
|
||||
/** Do not zoom in beyond this level even if the bounds would allow it */
|
||||
maxZoom?: number;
|
||||
/** Relative padding around the bounds, e.g. 0.5 grows them by 50% */
|
||||
pad?: number;
|
||||
/** Ease the camera to the bounds instead of jumping; defaults to true */
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export interface MapMarkerOptions {
|
||||
/** Rendered size of the element in pixels */
|
||||
size: [width: number, height: number];
|
||||
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
|
||||
anchor?: [x: number, y: number];
|
||||
/** Takes pointer input and keyboard focus; defaults to true */
|
||||
interactive?: boolean;
|
||||
/** Accessible name */
|
||||
title?: string;
|
||||
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
|
||||
decoration?: MapCircleOptions;
|
||||
/** Cluster this marker; it appears once setClustering is called */
|
||||
cluster?: boolean;
|
||||
/** Caller data handed back to the cluster icon builder */
|
||||
clusterData?: unknown;
|
||||
}
|
||||
|
||||
export interface MapCircleOptions {
|
||||
/** Radius in meters */
|
||||
radius: number;
|
||||
/** Stroke color; the fill is derived from it, translucent */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface MapPathSegment {
|
||||
points: MapLatLng[];
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface MapPathMarker {
|
||||
location: MapLatLng;
|
||||
opacity?: number;
|
||||
/** Tooltip/popup HTML shown on hover; caller is responsible for escaping */
|
||||
tooltipHtml: string;
|
||||
}
|
||||
|
||||
export interface MapPath {
|
||||
color: string;
|
||||
segments: MapPathSegment[];
|
||||
markers: MapPathMarker[];
|
||||
}
|
||||
|
||||
/** Handle to anything placed on the map; remove() must be idempotent */
|
||||
export interface MapItemHandle {
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
export interface MapMarkerHandle extends MapItemHandle {
|
||||
readonly location: MapLatLng;
|
||||
readonly clusterData?: unknown;
|
||||
}
|
||||
|
||||
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
|
||||
onDragEnd?(location: MapLatLng): void;
|
||||
}
|
||||
|
||||
export interface MapEditableCircleOptions {
|
||||
/** Radius in meters */
|
||||
radius: number;
|
||||
/** Stroke color; the fill is derived from it, translucent */
|
||||
color: string;
|
||||
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
|
||||
centerElement?: HTMLElement;
|
||||
centerSize?: [width: number, height: number];
|
||||
title?: string;
|
||||
/** The center can be dragged */
|
||||
moveable?: boolean;
|
||||
/** A handle on the edge can be dragged to change the radius */
|
||||
resizable?: boolean;
|
||||
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
|
||||
resizeLabel?: string;
|
||||
onMove?(center: MapLatLng): void;
|
||||
onResize?(radius: number): void;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
export interface MapEditingSupport {
|
||||
/** Place a draggable HTML element marker */
|
||||
addDraggableMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapDraggableMarkerOptions
|
||||
): MapEditableMarkerHandle;
|
||||
|
||||
/** Draw a circle whose center and radius can be dragged */
|
||||
addEditableCircle(
|
||||
center: MapLatLng,
|
||||
options: MapEditableCircleOptions
|
||||
): MapEditableCircleHandle;
|
||||
}
|
||||
|
||||
/** A circle with drag handles for its center and radius */
|
||||
export interface MapEditableCircleHandle extends MapItemHandle {
|
||||
readonly center: MapLatLng;
|
||||
readonly radius: number;
|
||||
/** Move and resize without recreating (no-op mid-drag) */
|
||||
update(center: MapLatLng, radius: number): void;
|
||||
}
|
||||
|
||||
export interface MapEditableMarkerHandle extends MapMarkerHandle {
|
||||
/** Move without recreating (no-op mid-drag) */
|
||||
setLocation(location: MapLatLng): void;
|
||||
}
|
||||
|
||||
export interface MapClusterIcon {
|
||||
element: HTMLElement;
|
||||
size: [width: number, height: number];
|
||||
/** Like MapMarkerOptions.anchor; defaults to the element's center */
|
||||
anchor?: [x: number, y: number];
|
||||
/** Show the icon here instead of at the cluster, e.g. attached to a zone */
|
||||
location?: MapLatLng;
|
||||
}
|
||||
|
||||
export interface MapClusterOptions {
|
||||
/** Cluster markers closer than this many screen pixels */
|
||||
radius: number;
|
||||
/**
|
||||
* Markers sharing a key (e.g. their zone) form one group while they span
|
||||
* at most groupRadius pixels; beyond that, and without a key, they cluster
|
||||
* by proximity.
|
||||
*/
|
||||
groupKey?(marker: MapMarkerHandle): string | undefined;
|
||||
groupRadius?: number;
|
||||
/** Builds a cluster's element; called when its members change and on refreshClusters() */
|
||||
iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon;
|
||||
}
|
||||
|
||||
export interface MapEngine {
|
||||
/** Create the map in the container; call once */
|
||||
init(container: HTMLElement, options: MapEngineOptions): Promise<void>;
|
||||
|
||||
/** Tear down the map and release its resources (DOM, workers, WebGL) */
|
||||
destroy(): void;
|
||||
|
||||
/** Re-measure the container after a size change */
|
||||
invalidateSize(): void;
|
||||
|
||||
/** Whether the map has a non-zero size, re-measuring if needed */
|
||||
hasUsableSize(): boolean;
|
||||
|
||||
setDarkMode(darkMode: boolean): void;
|
||||
|
||||
setZoomControlPosition(position: MapControlPosition): void;
|
||||
|
||||
/** Show a scale ruler (bottom start); null hides it */
|
||||
setScaleRuler(options: { metric: boolean } | null): void;
|
||||
|
||||
setView(center: MapLatLng, zoom?: number): void;
|
||||
|
||||
setZoom(zoom: number): void;
|
||||
|
||||
/** Fit the given points into view; a single point centers on it */
|
||||
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
|
||||
|
||||
/** Pan to the location, keeping the zoom */
|
||||
panTo(location: MapLatLng): void;
|
||||
|
||||
/** Whether the location is inside the current viewport */
|
||||
containsLocation(location: MapLatLng): boolean;
|
||||
|
||||
/** Place a caller-owned element on the map */
|
||||
addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapMarkerOptions
|
||||
): MapMarkerHandle;
|
||||
|
||||
/** Draw a meter-radius circle (zone radius) */
|
||||
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
|
||||
|
||||
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
|
||||
editing?: MapEditingSupport;
|
||||
|
||||
/** Draw one history trail (points with tooltips, connecting segments) */
|
||||
addPath(path: MapPath): MapItemHandle;
|
||||
|
||||
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
|
||||
setClustering(options: MapClusterOptions | null): void;
|
||||
|
||||
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
|
||||
refreshClusters(): void;
|
||||
}
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
|
||||
/** Great-circle distance in meters */
|
||||
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(b[0] - a[0]);
|
||||
const dLng = toRad(b[1] - a[1]);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
/** The point the given distance due east of center, e.g. for a resize handle */
|
||||
export const pointEastOf = (
|
||||
center: MapLatLng,
|
||||
distanceInMeters: number
|
||||
): MapLatLng => {
|
||||
const lngOffset =
|
||||
(distanceInMeters /
|
||||
(EARTH_RADIUS * Math.cos((center[0] * Math.PI) / 180))) *
|
||||
(180 / Math.PI);
|
||||
return [center[0], center[1] + lngOffset];
|
||||
};
|
||||
|
||||
/** Bounding box corners of a circle, for fitting a radius into view */
|
||||
export const circleBoundsPoints = (
|
||||
center: MapLatLng,
|
||||
radiusMeters: number
|
||||
): MapLatLng[] => {
|
||||
const latOffset = radiusMeters / 111320;
|
||||
const lngOffset =
|
||||
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
|
||||
return [
|
||||
[center[0] - latOffset, center[1] - lngOffset],
|
||||
[center[0] + latOffset, center[1] + lngOffset],
|
||||
];
|
||||
};
|
||||
+191
-2
@@ -42,6 +42,7 @@ export const updateHistoryState = (patch: Record<string, unknown>) => {
|
||||
*/
|
||||
export const replaceCurrentUrl = (url: string) => {
|
||||
mainWindow.history.replaceState(mainWindow.history.state, "", url);
|
||||
rememberCurrentEntry();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,6 +81,105 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a page with unsaved changes (e.g. the automation editor) veto
|
||||
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
|
||||
* true when navigation may proceed.
|
||||
*/
|
||||
export interface UnsavedChangesGuard {
|
||||
isDirty(): boolean;
|
||||
prompt(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
|
||||
|
||||
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.add(guard);
|
||||
};
|
||||
|
||||
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.delete(guard);
|
||||
};
|
||||
|
||||
const dirtyGuards = (): UnsavedChangesGuard[] =>
|
||||
[...unsavedChangesGuards].filter((guard) => guard.isDirty());
|
||||
|
||||
let pendingUnsavedPrompt: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* Counts navigations that changed the history entry, so a navigation held up
|
||||
* by an unsaved-changes prompt can tell whether a newer one has moved the app
|
||||
* on in the meantime.
|
||||
*/
|
||||
let committedNavigations = 0;
|
||||
|
||||
interface HistoryEntry {
|
||||
path: string;
|
||||
/** Path of the entry behind this one, stamped by `performNavigation`. */
|
||||
from?: string;
|
||||
}
|
||||
|
||||
const readEntry = (): HistoryEntry => ({
|
||||
path: currentPath(),
|
||||
from: mainWindow.history.state?.from,
|
||||
});
|
||||
|
||||
/**
|
||||
* How far a pop moved from `entry`, signed, or undefined when it cannot be told:
|
||||
* the entry behind us is the one our `from` names, the one ahead is the one
|
||||
* whose `from` names us. A stack with the same path on both sides matches both,
|
||||
* and back is then by far the likelier press.
|
||||
*/
|
||||
const popStep = (entry: HistoryEntry): number | undefined => {
|
||||
if (currentPath() === entry.from) {
|
||||
return -1;
|
||||
}
|
||||
return mainWindow.history.state?.from === entry.path ? 1 : undefined;
|
||||
};
|
||||
|
||||
let currentEntry: HistoryEntry = readEntry();
|
||||
|
||||
const rememberCurrentEntry = () => {
|
||||
currentEntry = readEntry();
|
||||
};
|
||||
|
||||
/** Where the pops held while a prompt is open left us. */
|
||||
let heldEntry: HistoryEntry | undefined;
|
||||
let heldSteps = 0;
|
||||
|
||||
/** The entry `goBack()` asked to leave; the pop it triggers is not prompted. */
|
||||
let popRequestedFromPath: string | undefined;
|
||||
|
||||
/**
|
||||
* Asks each dirty guard whether navigation may proceed. Returns true when
|
||||
* nothing is dirty or every prompt was confirmed. Concurrent navigations
|
||||
* share one pending prompt instead of stacking dialogs; the dirty check runs
|
||||
* before joining it, so a navigation triggered from inside a prompt (e.g. by
|
||||
* its save action) cannot deadlock on its own promise.
|
||||
*/
|
||||
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
|
||||
const guards = dirtyGuards();
|
||||
if (!guards.length) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (!pendingUnsavedPrompt) {
|
||||
pendingUnsavedPrompt = (async () => {
|
||||
try {
|
||||
for (const guard of guards) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await guard.prompt())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
pendingUnsavedPrompt = undefined;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return pendingUnsavedPrompt;
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
@@ -91,7 +191,7 @@ const buildHistoryState = (
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
@@ -124,12 +224,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
);
|
||||
}
|
||||
|
||||
rememberCurrentEntry();
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
committedNavigations += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
// Only guard actual departures: navigating to the current path keeps the
|
||||
// page, and any unsaved state on it, mounted.
|
||||
if (path !== currentPath()) {
|
||||
const navigationsBeforePrompt = committedNavigations;
|
||||
if (!(await ensureUnsavedChangesConfirmed())) {
|
||||
return false;
|
||||
}
|
||||
if (committedNavigations !== navigationsBeforePrompt) {
|
||||
// Another navigation landed while the prompt was waiting for an answer,
|
||||
// so this destination is stale. Dropping it keeps a late answer from
|
||||
// pulling the user back off the page they are on now.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return performNavigation(path, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
@@ -142,6 +262,9 @@ export const canGoBack = (): boolean =>
|
||||
/**
|
||||
* Navigate back to the page we came from, falling back to a path when the
|
||||
* previous entry is not ours (deep link, login redirect, fresh tab).
|
||||
* Deliberately not guarded against unsaved changes: pages with such a guard
|
||||
* confirm in their own back handlers, and delete flows leave through here
|
||||
* after the edited item is already gone.
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -152,9 +275,75 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
// Read after closing dialogs: their history entries are popped by then, so
|
||||
// this is the state of the page entry.
|
||||
if (canGoBack()) {
|
||||
popRequestedFromPath = currentEntry.path;
|
||||
mainWindow.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
await performNavigation(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a history pop before the router acts on it. A pop cannot be cancelled,
|
||||
* so one away from a page with unsaved changes holds the route and prompts;
|
||||
* `resume` runs once the user agrees to leave. The prompt must add no history
|
||||
* entry of its own, or the entries the pop left would be truncated.
|
||||
*/
|
||||
export const handleHistoryPop = (resume: () => void): void => {
|
||||
if (heldEntry) {
|
||||
const heldStep = popStep(heldEntry);
|
||||
if (heldStep !== undefined) {
|
||||
heldSteps += heldStep;
|
||||
heldEntry = readEntry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPath() === currentEntry.path) {
|
||||
// A dialog's history entry was popped, not a page.
|
||||
rememberCurrentEntry();
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
|
||||
const step = popStep(currentEntry);
|
||||
if (
|
||||
popRequestedFromPath === currentEntry.path ||
|
||||
// Not a pop we could undo, so do not hold it.
|
||||
step === undefined ||
|
||||
!dirtyGuards().length
|
||||
) {
|
||||
popRequestedFromPath = undefined;
|
||||
rememberCurrentEntry();
|
||||
committedNavigations += 1;
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
|
||||
heldSteps = step;
|
||||
heldEntry = readEntry();
|
||||
const navigationsAtPop = committedNavigations;
|
||||
ensureUnsavedChangesConfirmed().then(
|
||||
(confirmed) => {
|
||||
const steps = heldSteps;
|
||||
heldEntry = undefined;
|
||||
if (committedNavigations !== navigationsAtPop) {
|
||||
// A navigation landed while the prompt was open.
|
||||
return;
|
||||
}
|
||||
if (!confirmed) {
|
||||
// go(0) would reload the document, and at zero we are already back.
|
||||
if (steps) {
|
||||
mainWindow.history.go(-steps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rememberCurrentEntry();
|
||||
committedNavigations += 1;
|
||||
resume();
|
||||
},
|
||||
() => {
|
||||
heldEntry = undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,18 +10,15 @@ const VIEW_PARAM = "more-info-view";
|
||||
export interface MoreInfoUrlData {
|
||||
entityId?: string;
|
||||
view?: MoreInfoView;
|
||||
hash: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface CreateMoreInfoUrlData {
|
||||
entityId: string;
|
||||
view: MoreInfoView;
|
||||
hash?: URLSearchParams;
|
||||
}
|
||||
|
||||
export const decodeMoreInfoUrl = (
|
||||
search: SearchParamsSource,
|
||||
hash = ""
|
||||
search: SearchParamsSource
|
||||
): MoreInfoUrlData => {
|
||||
const params =
|
||||
typeof search === "string"
|
||||
@@ -35,9 +32,6 @@ export const decodeMoreInfoUrl = (
|
||||
return {
|
||||
entityId,
|
||||
view: isMoreInfoView(view) ? view : undefined,
|
||||
hash: new URLSearchParams(
|
||||
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -48,9 +42,6 @@ export const createMoreInfoUrl = (
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
|
||||
url.searchParams.set(VIEW_PARAM, data.view);
|
||||
if (!__DEMO__ && data.hash !== undefined) {
|
||||
url.hash = data.hash.toString();
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export const startMediaProgressInterval = (
|
||||
interval: number | undefined,
|
||||
callback: () => void,
|
||||
intervalMs = 1000
|
||||
): number => {
|
||||
if (interval) {
|
||||
return interval;
|
||||
}
|
||||
return window.setInterval(callback, intervalMs);
|
||||
};
|
||||
|
||||
export const stopMediaProgressInterval = (
|
||||
interval: number | undefined
|
||||
): number | undefined => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import deepFreeze from "deep-freeze";
|
||||
|
||||
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
|
||||
|
||||
export const shareInFlightRequest = <T>(
|
||||
owner: object,
|
||||
key: string,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
let requests = inFlightRequests.get(owner);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
inFlightRequests.set(owner, requests);
|
||||
}
|
||||
|
||||
const ownerRequests = requests;
|
||||
const existing = ownerRequests.get(key);
|
||||
if (existing) {
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = fetcher()
|
||||
.then((result) => deepFreeze(result) as T)
|
||||
.finally(() => {
|
||||
if (ownerRequests.get(key) !== request) {
|
||||
return;
|
||||
}
|
||||
|
||||
ownerRequests.delete(key);
|
||||
if (ownerRequests.size === 0) {
|
||||
inFlightRequests.delete(owner);
|
||||
}
|
||||
});
|
||||
|
||||
ownerRequests.set(key, request);
|
||||
return request;
|
||||
};
|
||||
@@ -3,6 +3,27 @@ import type { TooltipPositionCallback } from "echarts/types/dist/shared";
|
||||
export const TOOLTIP_GAP_PX = 12;
|
||||
export const TOOLTIP_TOP_OFFSET_PX = 10;
|
||||
|
||||
const offsetFromCursor = (
|
||||
cursorX: number,
|
||||
dom: unknown,
|
||||
viewW: number,
|
||||
tipW: number
|
||||
) => {
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
return Math.max(0, Math.min(x, viewW - tipW));
|
||||
};
|
||||
|
||||
/**
|
||||
* Pins the tooltip near the top of the chart and offsets it horizontally
|
||||
* from the cursor so it never covers the data point being inspected.
|
||||
@@ -20,21 +41,29 @@ export const sideTooltipPosition: TooltipPositionCallback = (
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
x = Math.max(0, Math.min(x, viewW - tipW));
|
||||
|
||||
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
|
||||
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
/**
|
||||
* Offsets the tooltip horizontally from the cursor and keeps it level with it.
|
||||
* For item-trigger tooltips where the cursor's row is what the tooltip shows.
|
||||
*/
|
||||
export const itemTooltipPosition: TooltipPositionCallback = (
|
||||
point,
|
||||
_params,
|
||||
dom,
|
||||
_rect,
|
||||
size
|
||||
) => {
|
||||
const [cursorX, cursorY] = point;
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
|
||||
const y = Math.max(0, Math.min(cursorY - tipH / 2, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
@@ -362,7 +362,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
if (Object.keys(chartOptions).length > 0) {
|
||||
this._setChartOptions(chartOptions);
|
||||
if (chartOptions.series) {
|
||||
if (chartOptions.series || changedProps.has("_isZoomed")) {
|
||||
this._updateSankeyRoam();
|
||||
}
|
||||
}
|
||||
@@ -654,6 +654,11 @@ export class HaChartBase extends LitElement {
|
||||
echarts.registerTheme("custom", this._createTheme(style));
|
||||
|
||||
this.chart = echarts.init(this._chartContainer!, "custom");
|
||||
if (this._isZoomed) {
|
||||
this._isZoomed = false;
|
||||
this._zoomRatio = 1;
|
||||
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
|
||||
}
|
||||
this.chart.on("datazoom", (e: any) => {
|
||||
this._handleDataZoomEvent(e);
|
||||
});
|
||||
@@ -664,7 +669,7 @@ export class HaChartBase extends LitElement {
|
||||
const option = this.chart!.getOption();
|
||||
const series = option.series as any[];
|
||||
const sankeySeries = series?.find((s: any) => s.type === "sankey");
|
||||
const zoomed = sankeySeries.zoom !== 1;
|
||||
const zoomed = Math.abs(sankeySeries.zoom - 1) > 1e-6;
|
||||
this._isZoomed = zoomed;
|
||||
if (!zoomed) {
|
||||
// Reset center when fully zoomed out
|
||||
@@ -1279,12 +1284,24 @@ export class HaChartBase extends LitElement {
|
||||
this.chart?.setOption({
|
||||
series: sankeySeries.map((s: any) => ({
|
||||
id: s.id,
|
||||
roam: this._modifierPressed || this._isTouchDevice ? true : "move",
|
||||
roam: this._getSankeyRoam(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// On touch devices a drag pans only once zoomed, so an unzoomed chart
|
||||
// does not swallow page scrolling. Pinch still zooms via "scale".
|
||||
private _getSankeyRoam(): boolean | "move" | "scale" {
|
||||
if (this._modifierPressed) {
|
||||
return true;
|
||||
}
|
||||
if (this._isTouchDevice) {
|
||||
return this._isZoomed ? true : "scale";
|
||||
}
|
||||
return "move";
|
||||
}
|
||||
|
||||
private _handleDataZoomEvent(e: any) {
|
||||
const zoomData = e.batch?.[0] ?? e;
|
||||
let start = typeof zoomData.start === "number" ? zoomData.start : 0;
|
||||
|
||||
@@ -328,6 +328,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
onFractionDigits: (digits) => {
|
||||
if (digits !== this._yAxisFractionDigits) {
|
||||
this._yAxisFractionDigits = digits;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -13,7 +12,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { itemTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
@@ -24,7 +23,6 @@ import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
const GRID_BOTTOM = 30;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
@@ -43,10 +41,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw each row's name above its bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -69,13 +63,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
private _width = 0;
|
||||
|
||||
private _resize = new ResizeController(this, {
|
||||
skipInitial: true,
|
||||
callback: (entries) => entries[0]?.contentRect.width,
|
||||
});
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
protected render() {
|
||||
@@ -83,7 +70,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
|
||||
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -193,19 +180,13 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
this._generateData();
|
||||
}
|
||||
|
||||
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
|
||||
const widthChanged = width !== this._width;
|
||||
this._width = width;
|
||||
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth") ||
|
||||
widthChanged
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -215,22 +196,14 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
// Keeps the plot aligned with the line charts sharing the y-axis padding.
|
||||
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
|
||||
// A zero width hides the labels instead of truncating them.
|
||||
const insideLabelWidth = this._width
|
||||
? Math.max(0, this._width - plotPadding - labelMargin)
|
||||
: undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -254,56 +227,41 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "bottom",
|
||||
width: insideLabelWidth,
|
||||
overflow: "truncate",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: insideLabels ? 20 : 10,
|
||||
top: 10,
|
||||
bottom: GRID_BOTTOM,
|
||||
left: rtl ? 1 : plotPadding,
|
||||
right: rtl ? plotPadding : 1,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
position: sideTooltipPosition,
|
||||
position: itemTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -443,10 +401,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-chart-base {
|
||||
--chart-max-height: none;
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw timeline row names above their bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -231,7 +227,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -429,12 +424,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Names inside the plot sit close to the chart above them, so the groups
|
||||
need more room between them to stay apart. */
|
||||
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -446,6 +446,7 @@ export class StatisticsChart extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
// Bar charts stay anchored at 0, so precision must reflect the
|
||||
// 0-based range that is actually rendered.
|
||||
includeZero: !yAxisScale,
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
|
||||
import { getPrecision, nice, round } from "echarts/lib/util/number";
|
||||
|
||||
// A range smaller than this fraction of the axis magnitude is floating-point
|
||||
// noise (e.g. from summed statistics), not real precision.
|
||||
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
|
||||
|
||||
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
|
||||
// precision derived here cannot drift from the ticks it renders.
|
||||
const SPLIT_NUMBER = 5;
|
||||
|
||||
// How thin a gap between the data and the plot edge counts as no gap at all,
|
||||
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
|
||||
// maximum to a tick multiple, which usually leaves headroom, but quantized
|
||||
// states often land exactly on a tick and get none — collapsing area-filled
|
||||
// series, which are drawn from their value down to the axis minimum. Widening
|
||||
// the extent by this much before that rounding bumps any axis with less
|
||||
// headroom out to a full tick, and leaves the rest where they are.
|
||||
const GAP_FRACTION_OF_SPAN = 0.02;
|
||||
|
||||
// A percentage has a real ceiling the way zero is a real floor, so the gap must
|
||||
// not push the axis past it. Not every `%` sensor is bounded — power factor is
|
||||
// signed and can read over 100 — so this only applies while the data stays under.
|
||||
const PERCENT_MAX = 100;
|
||||
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
// observed data range, by asking ECharts for the same tick interval it will
|
||||
// render. This matches the precision it actually draws, so labels are neither
|
||||
// truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -22,13 +41,7 @@ export function computeYAxisFractionDigits(
|
||||
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
|
||||
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
|
||||
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
// Rounding the mantissa to a nice value only ever carries to the next power
|
||||
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
|
||||
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
|
||||
return Math.max(0, -niceExponent);
|
||||
return getPrecision(nice(range / SPLIT_NUMBER, true));
|
||||
}
|
||||
|
||||
interface YAxisExtentValues {
|
||||
@@ -44,34 +57,116 @@ const resolveYAxisBound = (
|
||||
values: YAxisExtentValues
|
||||
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
|
||||
|
||||
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
|
||||
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
|
||||
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
|
||||
// always before the label formatter runs, so recomputing the fraction digits
|
||||
// here keeps zoomed-in labels distinct. The callbacks return the original
|
||||
// bounds unchanged, so auto-scaling still applies when a bound is not set.
|
||||
// A constant series has no span for the gap to be a fraction of, so ECharts
|
||||
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
|
||||
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
|
||||
// expansion a flat series relies on for its window. Run that expansion here and
|
||||
// return fixed bounds, which also suppresses the gap.
|
||||
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
|
||||
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
|
||||
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
|
||||
const precision = getPrecision(interval);
|
||||
return {
|
||||
min: round(Math.floor(lo / interval) * interval, precision),
|
||||
max: round(Math.ceil(hi / interval) * interval, precision),
|
||||
};
|
||||
};
|
||||
|
||||
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
|
||||
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
|
||||
// callbacks with the extent of the visible (zoom-filtered) data on every
|
||||
// dataZoom, and always before the label formatter runs, so the fraction digits
|
||||
// recomputed here track the zoomed range. A callback returns `undefined`
|
||||
// wherever auto-scaling should stand, and a number only where the axis has to be
|
||||
// pinned: an explicit bound, the zero anchor, or a constant series.
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
|
||||
// Such an axis also gets no gap: pushing it below zero would defeat the zero
|
||||
// anchoring and leave the bars floating above the axis.
|
||||
includeZero?: boolean;
|
||||
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
|
||||
// a limit the data itself never crosses.
|
||||
unit?: string;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
min: (values: YAxisExtentValues) => number | undefined;
|
||||
max: (values: YAxisExtentValues) => number | undefined;
|
||||
boundaryGap: [number, number];
|
||||
splitNumber: number;
|
||||
} {
|
||||
const { min, max, includeZero, onFractionDigits } = options;
|
||||
const { min, max, includeZero, unit, onFractionDigits } = options;
|
||||
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
|
||||
|
||||
const resolveBounds = (values: YAxisExtentValues) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
if (
|
||||
includeZero ||
|
||||
!Number.isFinite(values.min) ||
|
||||
!Number.isFinite(values.max)
|
||||
) {
|
||||
return { min: resolvedMin, max: resolvedMax, gap: 0 };
|
||||
}
|
||||
if (values.min === values.max) {
|
||||
const flat = flatSeriesExtent(values.min, [
|
||||
resolvedMin !== undefined,
|
||||
resolvedMax !== undefined,
|
||||
]);
|
||||
// The expansion is a fraction of the magnitude, so a constant series near
|
||||
// the ceiling would otherwise overshoot it too.
|
||||
const flatMax =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? Math.min(flat.max, naturalMax)
|
||||
: flat.max;
|
||||
return {
|
||||
min: resolvedMin ?? flat.min,
|
||||
max: resolvedMax ?? flatMax,
|
||||
gap: 0,
|
||||
};
|
||||
}
|
||||
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
|
||||
// Never let the gap carry a series past a boundary it does not itself cross.
|
||||
const floor = values.min >= 0 ? 0 : undefined;
|
||||
const ceiling =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? naturalMax
|
||||
: values.max <= 0
|
||||
? 0
|
||||
: undefined;
|
||||
return {
|
||||
min:
|
||||
resolvedMin ??
|
||||
(floor !== undefined && values.min - floor < gap ? floor : undefined),
|
||||
max:
|
||||
resolvedMax ??
|
||||
(ceiling !== undefined && ceiling - values.max < gap
|
||||
? ceiling
|
||||
: undefined),
|
||||
gap,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// Always emit the key. `setOption` merges the Y axis rather than replacing
|
||||
// it, so a conditionally spread gap would survive a chart switching to a
|
||||
// zero-anchored type and leave its bars floating.
|
||||
boundaryGap: includeZero
|
||||
? [0, 0]
|
||||
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
|
||||
splitNumber: SPLIT_NUMBER,
|
||||
min: (values) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
const bounds = resolveBounds(values);
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
computeYAxisFractionDigits(
|
||||
bounds.min ?? values.min - bounds.gap,
|
||||
bounds.max ?? values.max + bounds.gap,
|
||||
includeZero
|
||||
)
|
||||
);
|
||||
return resolvedMin;
|
||||
return bounds.min;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
max: (values) => resolveBounds(values).max,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,45 +204,17 @@ export class HaDataTable extends LitElement {
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public selectAll(): void {
|
||||
public selectAll(extraFilter?: (row: DataTableRowData) => boolean): void {
|
||||
this._checkedRows = (this._filteredData || [])
|
||||
.filter((data) => data.selectable !== false)
|
||||
.filter(
|
||||
(data) =>
|
||||
data.selectable !== false && (!extraFilter || extraFilter(data))
|
||||
)
|
||||
.map((data) => data[this.id]);
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public select(ids: string[], clear?: boolean): void {
|
||||
if (clear) {
|
||||
this._checkedRows = [];
|
||||
}
|
||||
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
|
||||
const rowLookup = new Map(
|
||||
(this._filteredData || []).map((data) => [data[this.id], data])
|
||||
);
|
||||
const checkedRows = new Set(this._checkedRows);
|
||||
ids.forEach((id) => {
|
||||
const row = rowLookup.get(id);
|
||||
if (row?.selectable !== false && !checkedRows.has(id)) {
|
||||
this._checkedRows.push(id);
|
||||
checkedRows.add(id);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public unselect(ids: string[]): void {
|
||||
ids.forEach((id) => {
|
||||
const index = this._checkedRows.indexOf(id);
|
||||
if (index > -1) {
|
||||
this._checkedRows.splice(index, 1);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._filteredData?.length) {
|
||||
@@ -1235,10 +1207,8 @@ export class HaDataTable extends LitElement {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.mdc-data-table__header-row {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mdc-data-table__cell,
|
||||
|
||||
@@ -12,7 +12,6 @@ import { fullEntitiesContext } from "../../data/context";
|
||||
import type { DeviceAutomation } from "../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationsEqual,
|
||||
deviceAutomationsSimilar,
|
||||
sortDeviceAutomations,
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
@@ -180,12 +179,15 @@ export abstract class HaDeviceAutomationPicker<
|
||||
(a, idx) => value === `${a.device_id}_${idx}`
|
||||
);
|
||||
|
||||
const text = automation
|
||||
const described =
|
||||
automation ?? (this.value?.domain ? this.value : undefined);
|
||||
|
||||
const text = described
|
||||
? this._localizeDeviceAutomation(
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this._entityReg,
|
||||
automation
|
||||
described
|
||||
)
|
||||
: value === NO_AUTOMATION_KEY
|
||||
? this.NO_AUTOMATION_TEXT
|
||||
@@ -195,29 +197,24 @@ export abstract class HaDeviceAutomationPicker<
|
||||
};
|
||||
|
||||
private async _updateDeviceInfo() {
|
||||
// Asking a removed device for its automations fails rather than returning
|
||||
// an empty list.
|
||||
this._automations = this.deviceId
|
||||
? (
|
||||
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
|
||||
await this._fetchDeviceAutomations(
|
||||
this.hass.callWS,
|
||||
this.deviceId
|
||||
).catch(() => [] as T[])
|
||||
).sort(sortDeviceAutomations)
|
||||
: // No device, clear the list of automations
|
||||
[];
|
||||
|
||||
// If there is no value, or if we have changed the device ID, reset the
|
||||
// value. When the device changed (for example after replacing a removed
|
||||
// device), try to keep the same automation type/subtype on the new device
|
||||
// before falling back to the first available automation.
|
||||
// If there is no value, or if we have changed the device ID, reset the value.
|
||||
if (!this.value || this.value.device_id !== this.deviceId) {
|
||||
const equivalent =
|
||||
this.value && this.deviceId
|
||||
? this._automations.find((automation) =>
|
||||
deviceAutomationsSimilar(automation, this.value!)
|
||||
)
|
||||
: undefined;
|
||||
this._setValue(
|
||||
equivalent ||
|
||||
(this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId))
|
||||
this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId)
|
||||
);
|
||||
}
|
||||
this._renderEmpty = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import { mdiSwapHorizontal } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-alert";
|
||||
@@ -104,6 +105,14 @@ export class HaDevicePicker extends LitElement {
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
/**
|
||||
* The split devices that can actually replace the current value, when the
|
||||
* caller knows better than this picker. Narrows the replacement candidates,
|
||||
* so the user is only asked to choose when there is a real choice.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public replacementDeviceIds?: string[];
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
@@ -187,7 +196,8 @@ export class HaDevicePicker extends LitElement {
|
||||
value: string | undefined,
|
||||
_devices: HomeAssistant["devices"],
|
||||
compositeSplits: DeviceCompositeSplits | undefined,
|
||||
items: (DevicePickerItem | string)[]
|
||||
items: (DevicePickerItem | string)[],
|
||||
replacementDeviceIds: string[] | undefined
|
||||
) => {
|
||||
if (!value || !compositeSplits || this.hass.devices[value]) {
|
||||
return undefined;
|
||||
@@ -203,7 +213,11 @@ export class HaDevicePicker extends LitElement {
|
||||
.filter((item): item is DevicePickerItem => typeof item !== "string")
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
|
||||
const candidates = split.split_ids.filter(
|
||||
(id) =>
|
||||
selectableIds.has(id) &&
|
||||
(!replacementDeviceIds || replacementDeviceIds.includes(id))
|
||||
);
|
||||
return { candidates, primaryId: split.primary_id };
|
||||
}
|
||||
);
|
||||
@@ -250,26 +264,26 @@ export class HaDevicePicker extends LitElement {
|
||||
};
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
replacementName: string | undefined
|
||||
) =>
|
||||
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
|
||||
(value: string) => {
|
||||
const deviceId = value;
|
||||
const device = this.hass.devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
// When the device was replaced and a replacement is available, show
|
||||
// the replacement device's name. Otherwise fall back to the normal
|
||||
// "not found" display of the raw id.
|
||||
if (replacementName) {
|
||||
// The removed device has no name left to show, so say what happened
|
||||
// to it instead. The alert below names the replacements. Without a
|
||||
// replacement, fall back to the normal "not found" display.
|
||||
if (isReplaced) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
style="color: var(--warning-color)"
|
||||
.path=${mdiAlertOutline}
|
||||
.path=${mdiSwapHorizontal}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${replacementName}</span>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced"
|
||||
)}</span
|
||||
>
|
||||
`;
|
||||
}
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
@@ -399,31 +413,23 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems()
|
||||
this._getItems(),
|
||||
this.replacementDeviceIds
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Only treat the value as "replaced" when there is an available
|
||||
// replacement device; otherwise fall back to normal "not found" behavior.
|
||||
const canReplace = !!replacement?.candidates.length;
|
||||
const replacementName = canReplace
|
||||
? computeDeviceName(
|
||||
this.hass.devices[
|
||||
replacement!.primaryId &&
|
||||
replacement!.candidates.includes(replacement!.primaryId)
|
||||
? replacement!.primaryId
|
||||
: replacement!.candidates[0]
|
||||
]
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const valueRenderer = this._valueRenderer(
|
||||
this._configEntryLookup,
|
||||
replacementName
|
||||
canReplace
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.noUnknownState=${canReplace}
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.disabled=${this.disabled}
|
||||
@@ -443,14 +449,9 @@ export class HaDevicePicker extends LitElement {
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
.unknownItemText=${
|
||||
replacement?.candidates.length
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_count",
|
||||
{ count: replacement.candidates.length }
|
||||
)
|
||||
: this.hass.localize("ui.components.device-picker.unknown")
|
||||
}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.device-picker.unknown"
|
||||
)}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
@@ -464,30 +465,52 @@ export class HaDevicePicker extends LitElement {
|
||||
}) {
|
||||
const { candidates } = replacement;
|
||||
|
||||
const replacementName =
|
||||
candidates.length === 1
|
||||
? computeDeviceName(this.hass.devices[candidates[0]])
|
||||
: undefined;
|
||||
// The split devices all inherit the composite's name, so the integration is
|
||||
// what tells them apart.
|
||||
const replacementDevice =
|
||||
candidates.length === 1 ? this.hass.devices[candidates[0]] : undefined;
|
||||
const replacementName = replacementDevice
|
||||
? computeDeviceName(replacementDevice)
|
||||
: undefined;
|
||||
const replacementDomain = replacementDevice?.primary_config_entry
|
||||
? this._configEntryLookup[replacementDevice.primary_config_entry]?.domain
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-alert alert-type="warning">
|
||||
${
|
||||
replacementName
|
||||
replacementName && replacementDomain
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
"ui.components.device-picker.device_replaced_by_one_integration",
|
||||
{
|
||||
device: replacementName,
|
||||
integration: domainToName(
|
||||
this.hass.localize,
|
||||
replacementDomain
|
||||
),
|
||||
}
|
||||
)
|
||||
: replacementName
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
)
|
||||
}
|
||||
<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
variant="warning"
|
||||
@click=${this._handleReplace}
|
||||
>
|
||||
${this.hass.localize("ui.components.device-picker.replace_device")}
|
||||
${
|
||||
candidates.length === 1
|
||||
? this.hass.localize("ui.components.device-picker.replace_update")
|
||||
: this.hass.localize("ui.components.device-picker.replace_choose")
|
||||
}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`;
|
||||
@@ -498,7 +521,8 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems()
|
||||
this._getItems(),
|
||||
this.replacementDeviceIds
|
||||
);
|
||||
if (!replacement?.candidates.length) {
|
||||
return;
|
||||
|
||||
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
getStatisticIds,
|
||||
getStatisticLabel,
|
||||
type StatisticsMetaData,
|
||||
} from "../../data/recorder";
|
||||
import { getStatisticIds } from "../../data/recorder_statistic_ids";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import "../ha-combo-box-item";
|
||||
|
||||
@@ -293,12 +293,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
|
||||
);
|
||||
overflow: hidden;
|
||||
color: var(--primary-text-color);
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-surface-backdrop-filter,
|
||||
none
|
||||
);
|
||||
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
|
||||
static styles = css`
|
||||
.issue-type {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
padding: var(--ha-alert-padding, 8px);
|
||||
display: flex;
|
||||
}
|
||||
.icon {
|
||||
|
||||
@@ -1061,31 +1061,18 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
-webkit-animation: sk-bounce 2s infinite ease-in-out;
|
||||
animation: sk-bounce 2s infinite ease-in-out;
|
||||
}
|
||||
.double-bounce2 {
|
||||
-webkit-animation-delay: -1s;
|
||||
animation-delay: -1s;
|
||||
}
|
||||
@-webkit-keyframes sk-bounce {
|
||||
0%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes sk-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ export class HaBadge extends LitElement {
|
||||
--ha-card-background,
|
||||
var(--card-background-color, white)
|
||||
);
|
||||
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
border-width: var(--ha-card-border-width, 1px);
|
||||
box-shadow: var(--ha-card-box-shadow, none);
|
||||
|
||||
@@ -432,13 +432,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
wa-drawer::part(dialog)::backdrop {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
@@ -470,10 +463,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
var(--card-background-color, var(--ha-color-surface-default))
|
||||
)
|
||||
);
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-bottom-sheet-surface-backdrop-filter,
|
||||
var(--ha-dialog-surface-backdrop-filter, none)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-surface-backdrop-filter,
|
||||
var(--ha-dialog-surface-backdrop-filter, none)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { STATE_RUNNING } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import {
|
||||
@@ -149,6 +150,7 @@ export class HaCameraStream extends LitElement {
|
||||
objectFit: this.fitMode,
|
||||
})}
|
||||
alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
|
||||
@load=${this._handleImageLoad}
|
||||
/>`;
|
||||
}
|
||||
|
||||
@@ -214,6 +216,10 @@ export class HaCameraStream extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleImageLoad() {
|
||||
fireEvent(this, "load");
|
||||
}
|
||||
|
||||
private _handleHlsStreams(ev: CustomEvent) {
|
||||
this._hlsStreams = ev.detail;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ export class HaCard extends LitElement {
|
||||
--ha-card-background,
|
||||
var(--card-background-color, white)
|
||||
);
|
||||
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
box-shadow: var(--ha-card-box-shadow, none);
|
||||
box-sizing: border-box;
|
||||
|
||||
+6
-6
@@ -1,12 +1,12 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import "../../../components/list/ha-list-nav";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-icon-next";
|
||||
import "./ha-svg-icon";
|
||||
import "./item/ha-list-item-button";
|
||||
import "./list/ha-list-nav";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@customElement("ha-config-navigation-list")
|
||||
class HaConfigNavigationList extends LitElement {
|
||||
@@ -56,7 +56,7 @@ export class HaControlSelect extends LitElement {
|
||||
this.updateComplete.then(() => {
|
||||
// eslint-disable-next-line lit/prefer-query-decorators
|
||||
const option = this.shadowRoot?.querySelector(
|
||||
`#option-${this.options![index].value}`
|
||||
`[data-index="${index}"]`
|
||||
) as HTMLElement;
|
||||
option?.focus();
|
||||
});
|
||||
@@ -144,7 +144,11 @@ export class HaControlSelect extends LitElement {
|
||||
this.options,
|
||||
(option) => option.value,
|
||||
(option, index) =>
|
||||
this._renderOption(option, index === this._tabbableIndex)
|
||||
this._renderOption(
|
||||
option,
|
||||
index,
|
||||
index === this._tabbableIndex
|
||||
)
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
@@ -159,12 +163,17 @@ export class HaControlSelect extends LitElement {
|
||||
return selectedIndex === -1 ? 0 : selectedIndex;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
|
||||
private _renderOption(
|
||||
option: ControlSelectOption,
|
||||
index: number,
|
||||
tabbable: boolean
|
||||
) {
|
||||
const isSelected = this.value === option.value;
|
||||
|
||||
return html`
|
||||
<div
|
||||
id=${`option-${option.value}`}
|
||||
data-index=${index}
|
||||
class=${classMap({
|
||||
option: true,
|
||||
selected: isSelected,
|
||||
@@ -323,8 +332,6 @@ export class HaControlSelect extends LitElement {
|
||||
.option .content span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
-webkit-hyphens: auto;
|
||||
-moz-hyphens: auto;
|
||||
hyphens: auto;
|
||||
}
|
||||
:host([vertical]) {
|
||||
|
||||
@@ -393,10 +393,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-surface-backdrop-filter,
|
||||
none
|
||||
);
|
||||
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
|
||||
box-shadow: var(--dialog-box-shadow, var(--wa-shadow-l));
|
||||
color: var(--primary-text-color);
|
||||
@@ -429,10 +425,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog)::backdrop {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
|
||||
@@ -95,6 +95,10 @@ export class HaDrawer extends LitElement {
|
||||
}
|
||||
|
||||
private _handleAfterHide(ev: Event) {
|
||||
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this.open = false;
|
||||
fireEvent(this, "hass-drawer-closed");
|
||||
|
||||
@@ -63,11 +63,6 @@ class HaFaded extends LitElement {
|
||||
}
|
||||
.faded {
|
||||
cursor: pointer;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 25%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(to bottom, black 25%, transparent 100%);
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Selector } from "../../data/selector";
|
||||
import {
|
||||
getSelectorInitialValue,
|
||||
getSelectorInitialValueOrUndefined,
|
||||
} from "./get-selector-initial-value";
|
||||
import type { HaFormData, HaFormSchema } from "./types";
|
||||
|
||||
interface ComputeInitialHaFormDataOptions {
|
||||
skipUnsupportedSelectors?: boolean;
|
||||
}
|
||||
|
||||
const setDefaultValue = (
|
||||
field: HaFormSchema,
|
||||
value: HaFormData | undefined
|
||||
@@ -18,7 +25,8 @@ const setDefaultValue = (
|
||||
};
|
||||
|
||||
export const computeInitialHaFormData = (
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[]
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[],
|
||||
options?: ComputeInitialHaFormDataOptions
|
||||
): Record<string, any> => {
|
||||
const data = {};
|
||||
schema.forEach((field) => {
|
||||
@@ -33,7 +41,7 @@ export const computeInitialHaFormData = (
|
||||
} else if ("default" in field) {
|
||||
data[field.name] = setDefaultValue(field, field.default);
|
||||
} else if (field.type === "expandable") {
|
||||
const expandableData = computeInitialHaFormData(field.schema);
|
||||
const expandableData = computeInitialHaFormData(field.schema, options);
|
||||
if (field.required || Object.keys(expandableData).length) {
|
||||
// Only add expandable data if it's required or any of its children have initial values.
|
||||
data[field.name] = expandableData;
|
||||
@@ -62,104 +70,11 @@ export const computeInitialHaFormData = (
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("selector" in field) {
|
||||
const selector: Selector = field.selector;
|
||||
|
||||
if ("device" in selector) {
|
||||
data[field.name] = selector.device?.multiple ? [] : "";
|
||||
} else if ("entity" in selector) {
|
||||
data[field.name] = selector.entity?.multiple ? [] : "";
|
||||
} else if ("area" in selector) {
|
||||
data[field.name] = selector.area?.multiple ? [] : "";
|
||||
} else if ("label" in selector) {
|
||||
data[field.name] = selector.label?.multiple ? [] : "";
|
||||
} else if ("boolean" in selector) {
|
||||
data[field.name] = false;
|
||||
} else if (
|
||||
"addon" in selector ||
|
||||
"attribute" in selector ||
|
||||
"file" in selector ||
|
||||
"icon" in selector ||
|
||||
"serial_port" in selector ||
|
||||
"template" in selector ||
|
||||
"text" in selector ||
|
||||
"theme" in selector ||
|
||||
"object" in selector
|
||||
) {
|
||||
data[field.name] = "";
|
||||
} else if ("number" in selector) {
|
||||
data[field.name] = selector.number?.min ?? 0;
|
||||
} else if ("select" in selector) {
|
||||
if (selector.select?.options.length) {
|
||||
const firstOption = selector.select.options[0];
|
||||
const val =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
data[field.name] = selector.select.multiple ? [val] : val;
|
||||
}
|
||||
} else if ("country" in selector) {
|
||||
if (selector.country?.countries?.length) {
|
||||
data[field.name] = selector.country.countries[0];
|
||||
}
|
||||
} else if ("language" in selector) {
|
||||
if (selector.language?.languages?.length) {
|
||||
data[field.name] = selector.language.languages[0];
|
||||
}
|
||||
} else if ("duration" in selector) {
|
||||
data[field.name] = {
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("time" in selector) {
|
||||
data[field.name] = "00:00:00";
|
||||
} else if ("date" in selector || "datetime" in selector) {
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
data[field.name] = `${now}T00:00:00`;
|
||||
} else if ("color_rgb" in selector) {
|
||||
data[field.name] = [0, 0, 0];
|
||||
} else if ("color_temp" in selector) {
|
||||
data[field.name] = selector.color_temp?.min_mireds ?? 153;
|
||||
} else if (
|
||||
"action" in selector ||
|
||||
"trigger" in selector ||
|
||||
"condition" in selector
|
||||
) {
|
||||
data[field.name] = [];
|
||||
} else if ("media" in selector || "target" in selector) {
|
||||
data[field.name] = {};
|
||||
} else if ("state" in selector) {
|
||||
data[field.name] = selector.state?.multiple ? [] : "";
|
||||
} else if ("choose" in selector) {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
if (!firstChoice) {
|
||||
data[field.name] = {};
|
||||
} else {
|
||||
data[field.name] = {
|
||||
active_choice: firstChoice,
|
||||
[firstChoice]: computeInitialHaFormData([
|
||||
{
|
||||
name: firstChoice,
|
||||
selector: selector.choose.choices[firstChoice].selector,
|
||||
},
|
||||
])[firstChoice],
|
||||
};
|
||||
}
|
||||
} else if ("numeric_threshold" in selector) {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
data[field.name] =
|
||||
type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
|
||||
);
|
||||
const initialValue = options?.skipUnsupportedSelectors
|
||||
? getSelectorInitialValueOrUndefined(field.selector)
|
||||
: getSelectorInitialValue(field.selector);
|
||||
if (initialValue !== undefined) {
|
||||
data[field.name] = initialValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,25 +1,90 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type { Selector } from "../../data/selector";
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorFallbackValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_FALLBACK_VALUES = {
|
||||
action: undefined,
|
||||
addon: undefined,
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: undefined,
|
||||
areas_display: undefined,
|
||||
attribute: undefined,
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: undefined,
|
||||
color_rgb: undefined,
|
||||
condition: undefined,
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: undefined,
|
||||
date: undefined,
|
||||
datetime: undefined,
|
||||
device: undefined,
|
||||
device_class: undefined,
|
||||
duration: undefined,
|
||||
entity: undefined,
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: undefined,
|
||||
floor: undefined,
|
||||
label: undefined,
|
||||
language: undefined,
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: undefined,
|
||||
object: undefined,
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: undefined,
|
||||
selector: undefined,
|
||||
serial_port: undefined,
|
||||
state: undefined,
|
||||
state_class: undefined,
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: undefined,
|
||||
template: undefined,
|
||||
text: undefined,
|
||||
time: undefined,
|
||||
icon: undefined,
|
||||
media: undefined,
|
||||
theme: undefined,
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: undefined,
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
},
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorFallbackValues;
|
||||
|
||||
/**
|
||||
* Value a selector already displays when no field value is set.
|
||||
* Used when enabling an optional service/trigger/condition field.
|
||||
*/
|
||||
export const getSelectorFallbackValue = (selector: Selector): unknown => {
|
||||
if ("constant" in selector) {
|
||||
return selector.constant?.value;
|
||||
}
|
||||
if ("boolean" in selector) {
|
||||
return false;
|
||||
}
|
||||
if ("number" in selector) {
|
||||
return selector.number?.min ?? 0;
|
||||
}
|
||||
if ("color_temp" in selector) {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
}
|
||||
return undefined;
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
|
||||
|
||||
return fallbackValue?.(selector as never);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorInitialValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_INITIAL_VALUES = {
|
||||
action: () => [],
|
||||
addon: () => "",
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: (selector) => (selector.area?.multiple ? [] : ""),
|
||||
areas_display: undefined,
|
||||
attribute: () => "",
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: (selector) => {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
|
||||
if (!firstChoice) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const childValue = getSelectorInitialValueOrUndefined(
|
||||
selector.choose.choices[firstChoice].selector
|
||||
);
|
||||
|
||||
return childValue === undefined
|
||||
? { active_choice: firstChoice }
|
||||
: { active_choice: firstChoice, [firstChoice]: childValue };
|
||||
},
|
||||
color_rgb: () => [0, 0, 0],
|
||||
condition: () => [],
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: (selector) => selector.country?.countries?.[0],
|
||||
date: () => new Date().toISOString().slice(0, 10),
|
||||
datetime: () => `${new Date().toISOString().slice(0, 10)} 00:00:00`,
|
||||
device: (selector) => (selector.device?.multiple ? [] : ""),
|
||||
device_class: (selector) =>
|
||||
selector.device_class?.multiple ? [] : undefined,
|
||||
duration: () => ({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
}),
|
||||
entity: (selector) => (selector.entity?.multiple ? [] : ""),
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: () => "",
|
||||
floor: undefined,
|
||||
label: (selector) => (selector.label?.multiple ? [] : ""),
|
||||
language: (selector) => selector.language?.languages?.[0],
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: (selector) => {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
|
||||
return type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
},
|
||||
object: (selector) => (selector.object?.multiple ? [] : ""),
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: (selector) => {
|
||||
const select = selector.select;
|
||||
|
||||
if (!select?.options.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstOption = select.options[0];
|
||||
const value =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
|
||||
return select.multiple ? [value] : value;
|
||||
},
|
||||
selector: undefined,
|
||||
serial_port: () => "",
|
||||
state: (selector) => (selector.state?.multiple ? [] : ""),
|
||||
state_class: (selector) => (selector.state_class?.multiple ? [] : undefined),
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: () => ({}),
|
||||
template: () => "",
|
||||
text: (selector) => (selector.text?.multiple ? [] : ""),
|
||||
time: () => "00:00:00",
|
||||
icon: () => "",
|
||||
media: (selector) => (selector.media?.multiple ? [] : {}),
|
||||
theme: () => "",
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: () => [],
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
},
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorInitialValues;
|
||||
|
||||
export const getSelectorInitialValueOrUndefined = (
|
||||
selector: Selector
|
||||
): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
|
||||
};
|
||||
|
||||
export const getSelectorInitialValue = (selector: Selector): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const initialValue = SELECTOR_INITIAL_VALUES[type];
|
||||
|
||||
if (!initialValue) {
|
||||
throw new Error(`Selector ${type} not supported in initial form data`);
|
||||
}
|
||||
|
||||
return initialValue(selector as never);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiPlaylistPlus } from "@mdi/js";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext } from "../data/context";
|
||||
@@ -111,10 +113,17 @@ 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;
|
||||
|
||||
@query("wa-popover") private _popover?: WaPopover;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig?: ContextType<typeof configContext>;
|
||||
@@ -125,6 +134,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@state() private _popoverWidth = 0;
|
||||
|
||||
@state() private _popoverMinHeight = 0;
|
||||
|
||||
@state() private _openedNarrow = false;
|
||||
|
||||
@state() private _unknownValue = false;
|
||||
@@ -148,7 +159,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
private _unsubscribeTinyKeys?: () => void;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("value")) {
|
||||
if (
|
||||
changedProperties.has("value") ||
|
||||
changedProperties.has("noUnknownState")
|
||||
) {
|
||||
this._setUnknownValue();
|
||||
}
|
||||
}
|
||||
@@ -232,9 +246,12 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
: html`
|
||||
<wa-popover
|
||||
.open=${this._pickerWrapperOpen}
|
||||
style="--body-width: ${this._popoverWidth}px;"
|
||||
style=${styleMap({
|
||||
"--body-width": `${this._popoverWidth}px`,
|
||||
"--body-min-height": `${this._popoverMinHeight}px`,
|
||||
})}
|
||||
without-arrow
|
||||
distance="-4"
|
||||
distance="0"
|
||||
.placement=${this.popoverPlacement}
|
||||
.for=${this.popoverAnchor ? null : "picker"}
|
||||
.anchor=${this.popoverAnchor ?? null}
|
||||
@@ -257,12 +274,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderComboBox(dialogMode = false) {
|
||||
if (!this._opened) {
|
||||
// The list sizes the popover, so it is rendered as it opens, not once shown.
|
||||
if (!this._pickerWrapperOpen && !this._opened) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-picker-combo-box
|
||||
id="combo-box"
|
||||
.shown=${this._opened}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.label=${this.searchLabel}
|
||||
.value=${this._selectedValue ?? this.value}
|
||||
@@ -287,6 +306,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
private _setUnknownValue = () => {
|
||||
const items = this.getItems();
|
||||
if (
|
||||
this.noUnknownState ||
|
||||
this.allowCustomValue ||
|
||||
this.value === undefined ||
|
||||
this.value === null ||
|
||||
@@ -330,6 +350,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
private _dialogOpened = () => {
|
||||
this._opened = true;
|
||||
// Filtering must not shrink the popover under the size it opened with.
|
||||
this._popoverMinHeight = this._popover?.body.offsetHeight ?? 0;
|
||||
fireEvent(this, "picker-opened");
|
||||
requestAnimationFrame(() => {
|
||||
// Set initial field value if needed
|
||||
@@ -363,6 +385,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
this._opened = false;
|
||||
this._pickerWrapperOpen = false;
|
||||
this._popoverMinHeight = 0;
|
||||
this._selectedValue = undefined;
|
||||
this._unsubscribeTinyKeys?.();
|
||||
fireEvent(this, "picker-closed");
|
||||
@@ -465,6 +488,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
/* The surface of a dropdown menu, not of a dialog. */
|
||||
--wa-panel-border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
@@ -477,14 +502,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
--ha-generic-picker-max-width,
|
||||
var(--ha-generic-picker-width, max(var(--body-width), 250px))
|
||||
);
|
||||
max-height: 500px;
|
||||
height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--ha-box-shadow-m);
|
||||
height: fit-content;
|
||||
min-height: var(--body-min-height, 0);
|
||||
max-height: min(70vh, 500px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-height: 1000px) {
|
||||
wa-popover::part(body) {
|
||||
max-height: 400px;
|
||||
max-height: min(70vh, 400px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export class HaHeaderBar extends LitElement {
|
||||
--app-header-background-color,
|
||||
var(--primary-background-color)
|
||||
);
|
||||
backdrop-filter: var(--app-header-backdrop-filter, none);
|
||||
padding: var(--header-bar-padding);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ export class HaMarkdown extends LitElement {
|
||||
display: block;
|
||||
}
|
||||
ha-markdown-element {
|
||||
-ms-user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
ha-markdown-element > *:first-child {
|
||||
margin-top: 0;
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { mdiDelete, mdiPlayBox, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { MediaPickedEvent } from "../data/media-player";
|
||||
import {
|
||||
MediaClassBrowserSettings,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../data/selector";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-form/ha-form";
|
||||
import type { SchemaUnion } from "./ha-form/types";
|
||||
import "./media-player/ha-media-browser-thumbnail";
|
||||
import { showMediaBrowserDialog } from "./media-player/show-media-browser-dialog";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import "./ha-picture-upload";
|
||||
|
||||
const MANUAL_SCHEMA = [
|
||||
{ name: "media_content_id", required: false, selector: { text: {} } },
|
||||
{ name: "media_content_type", required: false, selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
const INCLUDE_DOMAINS = ["media_player"];
|
||||
|
||||
const EMPTY_FORM = {};
|
||||
|
||||
@customElement("ha-media-item-picker")
|
||||
export class HaMediaItemPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: MediaSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: MediaSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public required = true;
|
||||
|
||||
@property({ type: Boolean }) public multiple = false;
|
||||
|
||||
@property({ attribute: false }) public context?: {
|
||||
filter_entity?: string | string[];
|
||||
};
|
||||
|
||||
private _contextEntities: string[] | undefined;
|
||||
|
||||
private get _hasAccept(): boolean {
|
||||
return !!this.selector?.media?.accept?.length;
|
||||
}
|
||||
|
||||
willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("context")) {
|
||||
if (!this._hasAccept) {
|
||||
this._contextEntities = ensureArray(this.context?.filter_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const entityId = this._getActiveEntityId();
|
||||
|
||||
const stateObj = entityId ? this.hass.states[entityId] : undefined;
|
||||
|
||||
const supportsBrowse =
|
||||
!entityId ||
|
||||
(stateObj &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
|
||||
|
||||
if (this.selector.media?.image_upload && !this.value) {
|
||||
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${null}
|
||||
.contentIdHelper=${this.selector.media?.content_id_helper}
|
||||
select-media
|
||||
full-media
|
||||
@media-picked=${this._pictureUploadMediaPicked}
|
||||
></ha-picture-upload>`;
|
||||
}
|
||||
|
||||
const hideEntityPicker =
|
||||
this._hasAccept ||
|
||||
(this._contextEntities && this._contextEntities.length <= 1);
|
||||
return html`
|
||||
${
|
||||
hideEntityPicker
|
||||
? nothing
|
||||
: html`
|
||||
<ha-entity-picker
|
||||
.value=${entityId}
|
||||
.label=${
|
||||
this.label ||
|
||||
this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media_player"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.required=${this.required}
|
||||
.hideClearIcon=${!!this._contextEntities}
|
||||
.includeDomains=${INCLUDE_DOMAINS}
|
||||
.includeEntities=${this._contextEntities}
|
||||
.allowCustomEntity=${!this._contextEntities}
|
||||
@value-changed=${this._entityChanged}
|
||||
></ha-entity-picker>
|
||||
`
|
||||
}
|
||||
${
|
||||
!supportsBrowse
|
||||
? html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.components.selectors.media.browse_not_supported"
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.value || EMPTY_FORM}
|
||||
.schema=${MANUAL_SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
></ha-form>
|
||||
`
|
||||
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-card
|
||||
outlined
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title || this.value.media_content_id
|
||||
}
|
||||
@click=${this._pickMedia}
|
||||
@keydown=${this._handleKeyDown}
|
||||
class=${
|
||||
this.disabled || (!entityId && !this._hasAccept)
|
||||
? "disabled"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div class="content-container">
|
||||
<div class="thumbnail">
|
||||
${
|
||||
this.value?.metadata?.thumbnail
|
||||
? html`
|
||||
<div
|
||||
class="${classMap({
|
||||
"centered-image":
|
||||
!!this.value.metadata.media_class &&
|
||||
["app", "directory"].includes(
|
||||
this.value.metadata.media_class
|
||||
),
|
||||
})}
|
||||
image"
|
||||
>
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${this.value.metadata.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="icon-holder image">
|
||||
<ha-svg-icon
|
||||
class="folder"
|
||||
.path=${
|
||||
!this.value?.media_content_id
|
||||
? mdiPlus
|
||||
: this.value?.metadata?.media_class
|
||||
? MediaClassBrowserSettings[
|
||||
this.value.metadata.media_class ===
|
||||
"directory"
|
||||
? this.value.metadata
|
||||
.children_media_class ||
|
||||
this.value.metadata.media_class
|
||||
: this.value.metadata.media_class
|
||||
].icon
|
||||
: mdiPlayBox
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="title">
|
||||
${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title ||
|
||||
this.value.media_content_id
|
||||
}
|
||||
</div>
|
||||
${
|
||||
!this.multiple || !this.value
|
||||
? nothing
|
||||
: html`
|
||||
<div class="delete">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._clearValue}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
${
|
||||
this.selector.media?.image_upload
|
||||
? html`<div>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
variant="danger"
|
||||
@click=${this._clearValue}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.picture-upload.clear_picture"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
${
|
||||
hideEntityPicker && this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}`);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}_detail`);
|
||||
|
||||
private _entityChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!this._hasAccept && this.context?.filter_entity) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
metadata: {
|
||||
browse_entity_id: ev.detail.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
entity_id: ev.detail.value,
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _pickMedia() {
|
||||
showMediaBrowserDialog(this, {
|
||||
action: "pick",
|
||||
entityId: this._getActiveEntityId(),
|
||||
navigateIds: this.value?.metadata?.navigateIds,
|
||||
accept: this.selector.media?.accept,
|
||||
defaultId: this.value?.media_content_id,
|
||||
defaultType: this.value?.media_content_type,
|
||||
hideContentType: this.selector.media?.hide_content_type,
|
||||
contentIdHelper: this.selector.media?.content_id_helper,
|
||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
...(!this._hasAccept && this.context?.filter_entity
|
||||
? { browse_entity_id: this._getActiveEntityId() }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getActiveEntityId(): string | undefined {
|
||||
const metaId = this.value?.metadata?.browse_entity_id;
|
||||
return (
|
||||
this.value?.entity_id ||
|
||||
(metaId && this._contextEntities?.includes(metaId) && metaId) ||
|
||||
this._contextEntities?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._pickMedia();
|
||||
}
|
||||
}
|
||||
|
||||
private _pictureUploadMediaPicked(ev) {
|
||||
const pickedMedia = ev.detail as MediaPickedEvent;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _clearValue(ev) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: background-color 180ms ease-in-out;
|
||||
min-height: 56px;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-card:hover:not(.disabled),
|
||||
ha-card:focus:not(.disabled) {
|
||||
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
ha-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
ha-card.disabled {
|
||||
pointer-events: none;
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
.content-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
ha-card .thumbnail {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .image {
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
.folder {
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
--ha-media-browser-thumbnail-fit: cover;
|
||||
}
|
||||
.centered-image {
|
||||
margin: 4px;
|
||||
--ha-media-browser-thumbnail-fit: contain;
|
||||
}
|
||||
.icon-holder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-media-item-picker": HaMediaItemPicker;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user