Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub 16f2f31348 Merge remote-tracking branch 'origin/dev' into condition-state-attribute-no-for
# Conflicts:
#	src/translations/en.json
2026-08-26 08:46:11 +00:00
Bram KragtenandClaude Opus 4.8 8d7ce043d9 Hide "for" on state condition when matching an attribute
The legacy state condition measures the `for` duration against
`last_changed`, which only updates on state changes, not attribute
changes. So `for` together with `attribute` is unreliable and core is
moving to reject it. Omit the `for` field when an attribute is selected,
drop any lingering duration value, and explain why via a helper.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 16:31:06 +02:00
405 changed files with 6216 additions and 25032 deletions
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:javascript-typescript"
-1
View File
@@ -74,4 +74,3 @@ test/e2e/app/dist/
.serena
test/benchmarks/results/
+1 -1
View File
@@ -1 +1 @@
24.20.0
24.19.0
File diff suppressed because one or more lines are too long
+1 -9
View File
@@ -34,14 +34,6 @@ 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 compatible with older browsers (legacy build)
- Generate a second version that is ES5 compatible
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.
+3 -1
View File
@@ -121,7 +121,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
ignoreModuleNotFound: true,
},
],
// Import helpers from runtime package.
// Import helpers and regenerator 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,6 +155,8 @@ 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}/`)),
],
},
+2 -2
View File
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
16: [11, 0, 0],
17: [12, 0, 0],
18: [13, 0, 0],
26: [14, 6, 0],
26: [26, 0, 0],
};
const getCommonTemplateVars = () => {
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
...htmlMinifierOptions,
conservativeCollapse: false,
minifyJS: terserOptions({
latestBuild: false, // Shared scripts must satisfy the legacy targets
latestBuild: false, // Shared scripts should be ES5
isTestBuild: true, // Don't need source maps
}),
}).then((wrapped) =>
+41 -17
View File
@@ -4,7 +4,6 @@ import fs from "fs-extra";
import gulp from "gulp";
import path from "path";
import paths from "../paths.cjs";
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
const npmPath = (...parts) =>
path.resolve(paths.root_dir, "node_modules", ...parts);
@@ -42,6 +41,37 @@ 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
@@ -59,7 +89,7 @@ function copyQrScannerWorker(staticDir) {
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
}
async function copyMapPanel(staticDir) {
function copyMapPanel(staticDir) {
const staticPath = genStaticPath(staticDir);
copyFileDir(
npmPath("leaflet/dist/leaflet.css"),
@@ -73,16 +103,6 @@ async function copyMapPanel(staticDir) {
npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/")
);
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
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) {
@@ -112,13 +132,14 @@ 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
await copyMapPanel(staticDir);
copyMapPanel(staticDir);
// Qr Scanner assets
copyZXingWasm(staticDir);
@@ -133,7 +154,8 @@ gulp.task("copy-static-demo", async () => {
);
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
await copyMapPanel(paths.demo_output_static);
copyPolyfills(paths.demo_output_static);
copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
copyLocaleData(paths.demo_output_static);
@@ -145,7 +167,8 @@ 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);
await copyMapPanel(paths.cast_output_static);
copyPolyfills(paths.cast_output_static);
copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
copyLocaleData(paths.cast_output_static);
@@ -161,7 +184,7 @@ gulp.task("copy-static-gallery", async () => {
paths.gallery_output_root
);
await copyMapPanel(paths.gallery_output_static);
copyMapPanel(paths.gallery_output_static);
copyFonts(paths.gallery_output_static);
copyTranslations(paths.gallery_output_static);
copyLocaleData(paths.gallery_output_static);
@@ -191,7 +214,8 @@ gulp.task("copy-static-e2e-test-app", async () => {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
await copyMapPanel(paths.e2eTestApp_output_static);
copyPolyfills(paths.e2eTestApp_output_static);
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 ?? [];
const stateClasses = [...(data.state_classes ?? [])].sort();
const stateClassUnits = data.state_class_units ?? {};
if (
!numericDeviceClasses.length ||
-1
View File
@@ -14,7 +14,6 @@ import "./gen-icons-json.js";
import "./gen-sensor-entity-constants.js";
import "./landing-page.js";
import "./locale-data.js";
import "./map-assets.js";
import "./rspack.js";
import "./service-worker.js";
import "./translations.js";
-86
View File
@@ -1,86 +0,0 @@
// 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;
-82
View File
@@ -1,82 +0,0 @@
// Adds the English name to labels whose local name is not in Latin script.
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
const NAME = ["get", "name"];
const NAME_EN = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
// Streets are line-placed and cannot break lines.
const withEnglish = (placement) =>
placement === "line"
? ["concat", NAME, " (", NAME_EN, ")"]
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
// The OSMF Shortbread tiles carry some places twice: once as the place node
// and once as the centroid of its boundary area, tens of pixels apart. The
// style renders every feature of a kind, so those show as doubled labels.
// VersaTiles' own tiles dedupe at generation and are unaffected.
//
// A style expression only ever sees one feature, so the copies cannot be
// compared to each other; the population tag is the proxy. It lives on the
// place node, and towns and larger are tagged with one nearly without
// exception, so requiring it keeps the node and drops the centroid copy.
// Smaller places often lack the tag, so filtering them would erase real
// labels; they get a collision padding instead, which only ever hides a label
// that overlaps another one.
const POPULATED_PLACE_KINDS = ["town", "city", "state_capital", "capital"];
const SMALL_PLACE_PADDING = 24;
const isPlaceLabel = (layer) => layer["source-layer"] === "place_labels";
// The style filters places as ["==", ["get", "kind"], "<kind>"]
const placeKind = (layer) =>
Array.isArray(layer.filter) && layer.filter[0] === "=="
? layer.filter[2]
: undefined;
const dedupePlaceLabel = (layer) => {
if (!isPlaceLabel(layer)) {
return layer;
}
if (POPULATED_PLACE_KINDS.includes(placeKind(layer))) {
return {
...layer,
filter: ["all", layer.filter, ["has", "population"]],
};
}
return {
...layer,
layout: { ...layer.layout, "text-padding": SMALL_PLACE_PADDING },
};
};
export const addLatinLabels = (style) => ({
...style,
layers: style.layers.map((layer) => {
if (!isNameLabel(layer)) {
return layer;
}
return dedupePlaceLabel({
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
});
}),
});
+27 -62
View File
@@ -1,55 +1,38 @@
#!/usr/bin/env node
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
import { transformSync } from "@babel/core";
import compilationTargets, {
getInclusionReasons,
} from "@babel/helper-compilation-targets";
import { version as babelVersion } from "@babel/core";
import presetEnv from "@babel/preset-env";
import compilationTargets 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";
// 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}`);
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-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 && shippedProposalsList.has(name)) {
if (shippedProposals && shippedPolyfills.default.has(name)) {
return true;
}
if (name.startsWith("esnext.")) {
@@ -64,9 +47,7 @@ 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.find(
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
)?.[1];
const presetEnvOpts = babelOpts.presets[0][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(
@@ -74,38 +55,22 @@ for (const buildType of ["Modern", "Legacy"]) {
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
)?.[1];
// 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;
// Invoking preset-env in debug mode will log the included plugins
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
transformSync("", {
...babelOpts,
configFile: false,
filename: "audit.js",
caller: {
name: "list-plugins-and-polyfills",
supportsStaticESM: true,
supportsDynamicImport: true,
supportsTopLevelAwait: true,
supportsExportNamespaceFrom: true,
},
presetEnv.default(dummyAPI, {
...presetEnvOpts,
browserslistEnv,
debug: 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(babelOpts.targets, {
const targets = compilationTargets.default(babelOpts?.targets, {
browserslistEnv,
});
// `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(
const polyfillList = coreJSCompat({ targets }).list.filter(
polyfillFilter(
corejsOpts.method,
corejsOpts.proposals,
+4 -1
View File
@@ -345,7 +345,10 @@ 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$": "lit/directives/keyed.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-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
+3 -1
View File
@@ -15,6 +15,7 @@ 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";
@@ -196,7 +197,8 @@ class HcCast extends LitElement {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this.toggleAttribute(
toggleAttribute(
this,
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
-4
View File
@@ -3,7 +3,6 @@ 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";
@@ -54,9 +53,6 @@ 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
+1 -11
View File
@@ -7,12 +7,6 @@ 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";
@@ -66,7 +60,6 @@ const CONFIG_PANEL_COMMANDS = [
"assist_pipeline/",
"config/entity_registry/settings/",
"slugify",
...connectivityCommands,
];
@customElement("ha-demo")
@@ -94,7 +87,6 @@ export class HaDemo extends HomeAssistantAppEl {
"assist_pipeline",
"hassio",
"hardware",
...connectivityComponents,
],
},
});
@@ -107,7 +99,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass, localizePromise);
mockTranslations(hass);
mockHistory(hass);
mockRecorder(hass);
mockTodo(hass);
@@ -180,11 +172,9 @@ 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(
-8
View File
@@ -7,7 +7,6 @@ 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";
@@ -27,7 +26,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockCloud(hass);
mockConfig(hass);
mockConfigEntries(hass);
mockConnectivity(hass);
mockDeviceAutomation(hass);
mockEntitySources(hass);
mockBlueprint(hass);
@@ -45,10 +43,4 @@ 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" },
],
}));
};
-12
View File
@@ -5,7 +5,6 @@ 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",
@@ -81,17 +80,6 @@ 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?: {
@@ -1,71 +0,0 @@
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]] }
),
],
};
@@ -1,193 +0,0 @@
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);
};
});
};
-60
View File
@@ -1,60 +0,0 @@
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;
}, {});
-129
View File
@@ -1,129 +0,0 @@
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();
-25
View File
@@ -1,25 +0,0 @@
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));
};
@@ -1,107 +0,0 @@
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",
},
},
};
@@ -1,119 +0,0 @@
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",
},
},
}),
};
-404
View File
@@ -1,404 +0,0 @@
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,
};
});
};
@@ -1,90 +0,0 @@
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" },
},
}),
};
-192
View File
@@ -1,192 +0,0 @@
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: [] }
);
};
@@ -1,69 +0,0 @@
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",
},
},
};
@@ -1,25 +0,0 @@
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,
}));
};
@@ -1,6 +0,0 @@
import type { ConnectivityFixtures } from "../types";
export const serialFixtures: ConnectivityFixtures = {
components: ["usb"],
commands: ["usb/"],
};
-107
View File
@@ -1,107 +0,0 @@
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);
};
@@ -1,10 +0,0 @@
// 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);
};
@@ -1,6 +0,0 @@
import type { ConnectivityFixtures } from "../types";
export const tagsFixtures: ConnectivityFixtures = {
components: ["tag"],
commands: ["tag/"],
};
@@ -1,31 +0,0 @@
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"),
},
],
};
-441
View File
@@ -1,441 +0,0 @@
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 };
}
);
};
-38
View File
@@ -1,38 +0,0 @@
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>>
>;
}
-232
View File
@@ -1,232 +0,0 @@
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",
},
},
};
-793
View File
@@ -1,793 +0,0 @@
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;
}
);
};
@@ -1,201 +0,0 @@
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" },
},
}),
};
@@ -1,229 +0,0 @@
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,
})
)
);
};
-2
View File
@@ -1,5 +1,4 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
const baseDevice = {
config_entries_subentries: {},
@@ -87,5 +86,4 @@ export const demoDevices: DeviceRegistryEntry[] = [
entry_type: null,
parent_device_id: "power-strip",
},
...connectivityDevices,
];
+16 -11
View File
@@ -1,7 +1,19 @@
import type { IntegrationManifest } from "../../../src/data/integration";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { connectivityManifests } from "./connectivity/fixtures";
import { manifest } from "./manifest";
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,
});
const manifests: IntegrationManifest[] = [
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
@@ -50,18 +62,11 @@ const manifests: IntegrationManifest[] = [
integration_type: "helper",
iot_class: "local_polling",
}),
...connectivityManifests,
];
export const mockIntegration = (hass: MockHomeAssistant) => {
hass.mockWS("manifest/list", () => manifests);
// 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)
hass.mockWS("manifest/get", (msg: { integration: string }) =>
manifests.find((m) => m.domain === msg.integration)
);
};
-20
View File
@@ -1,20 +0,0 @@
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,
});
+2 -54
View File
@@ -1,58 +1,6 @@
import type { Tag, UpdateTagParams } from "../../../src/data/tag";
import type { Tag } from "../../../src/data/tag";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockTags = (hass: MockHomeAssistant) => {
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;
});
hass.mockWS("tag/list", () => [{ id: "my-tag", name: "My Tag" }] as Tag[]);
};
+4 -23
View File
@@ -1,26 +1,7 @@
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,
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))
)
);
export const mockTranslations = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_translations", (
/* msg: {language: string, category: string} */
) => ({ resources: {} }));
};
+3 -1
View File
@@ -693,7 +693,9 @@ class HaGallery extends LitElement {
haStyle,
css`
:host {
user-select: initial;
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 300px;
--ha-sidebar-expanded-width: 300px;
--ha-sidebar-expanded-item-width: 292px;
@@ -424,23 +424,6 @@ const SCHEMAS: {
},
},
},
device_class: {
name: "Device Class",
selector: {
device_class: {
domain: "sensor",
},
},
},
device_class_multiple: {
name: "Device Class (Multiple)",
selector: {
device_class: {
domain: "binary_sensor",
multiple: true,
},
},
},
select_custom: {
name: "Select (Custom)",
selector: {
-19
View File
@@ -16,25 +16,6 @@ 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")
+24 -24
View File
@@ -47,12 +47,12 @@
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.3",
"@codemirror/view": "6.43.11",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.9",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.1",
"@formatjs/intl-datetimeformat": "7.6.0",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
@@ -75,8 +75,6 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.5.0",
@@ -85,37 +83,39 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@tsparticles/engine": "4.3.2",
"@tsparticles/preset-links": "4.3.2",
"@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",
"comlink": "4.4.2",
"core-js": "3.50.0",
"cropperjs": "1.6.3",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
"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.2",
"hls.js": "1.7.1",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.1",
"js-yaml": "5.3.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.11",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -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.4.1",
"@octokit/auth-oauth-device": "8.0.5",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.4",
"@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.2.2",
"@rspack/core": "2.1.10",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -162,6 +162,7 @@
"@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",
@@ -169,14 +170,13 @@
"@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.1",
"eslint": "10.9.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -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.5",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.12.0",
"globals": "17.11.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.4.1",
"lint-staged": "17.3.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"minify-literals": "2.1.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.69.0",
"typescript-eslint": "8.67.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.20.0"
"node": "24.19.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.0"
version = "20260729.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
-1
View File
@@ -27,7 +27,6 @@ const ALLOWED_LICENSES = new Set([
"0BSD",
"CC0-1.0",
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
+3 -7
View File
@@ -62,16 +62,12 @@ export function computeCssColor(color: string): string {
/**
* Get a color from document's styles
* @param color - Named theme color (examples: `red`, `primary-text`)
* @param style - Styles to resolve against, defaults to the document body
* @returns Resolved color; initial color if not found in the styles
* @returns Resolved color; initial color if not found in document's styles
*/
export function resolveThemeColor(
color: string,
style?: CSSStyleDeclaration
): string {
export function resolveThemeColor(color: string): string {
const cssColor = computeCssVariableName(color);
if (cssColor.startsWith("--")) {
const resolved = (style ?? getComputedStyle(document.body))
const resolved = getComputedStyle(document.body)
.getPropertyValue(cssColor)
.trim();
return resolved || color;
+5 -22
View File
@@ -1,4 +1,4 @@
import { parse, wcagLuminance, wcagContrast } from "culori";
import { wcagLuminance, wcagContrast } from "culori";
import { theme2hex } from "./convert-color";
/**
@@ -51,28 +51,11 @@ export const getRGBContrastRatio = (
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
/**
* 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
* Returns a contrasted color (black or white) based on the luminance of another color
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
* @returns HEX color, whichever of black and white has the higher contrast ratio
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
*/
export const getContrastedColorHex = (color: string): string => {
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";
const lum = wcagLuminance(theme2hex(color));
return lum > 0.5 ? "#000000" : "#ffffff";
};
@@ -33,6 +33,7 @@ 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 {
@@ -1,152 +0,0 @@
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;
}
}
@@ -1,70 +0,0 @@
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;
}
}
+10 -5
View File
@@ -145,11 +145,16 @@ export const applyThemesOnElement = (
element.__themes = { cacheKey, keys: newTheme?.keys };
// Set and/or reset styles
for (const s in styles) {
if (s === null) {
element.style.removeProperty(s);
} else {
element.style.setProperty(s, styles[s]);
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]);
}
}
}
};
+20 -31
View File
@@ -1,27 +1,13 @@
import type { Map } from "leaflet";
import type { MapBaseLayer } from "../map/base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
import type { Map, TileLayer } from "leaflet";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
export interface LeafletMapSetup {
map: Map;
leaflet: LeafletModuleType;
baseLayer: MapBaseLayer;
}
export const setupLeafletMap = async (
mapElement: HTMLElement,
initialView?: {
latitude: number;
longitude: number;
zoom?: number;
darkMode?: boolean;
token?: string;
}
): Promise<LeafletMapSetup> => {
initialView?: { latitude: number; longitude: number; zoom?: number }
): Promise<[Map, LeafletModuleType, TileLayer]> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
}
@@ -31,11 +17,7 @@ export const setupLeafletMap = async (
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
const map = Leaflet.map(mapElement);
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet");
@@ -56,14 +38,21 @@ export const setupLeafletMap = async (
);
}
// The base layer adds itself: the vector layer only builds its MapLibre map
// once it is on the map, and that failing has to fall back to raster.
const baseLayer = await createBaseLayer(
Leaflet,
map,
initialView?.darkMode ?? false,
initialView?.token
);
const tileLayer = createTileLayer(Leaflet).addTo(map);
return { map, leaflet: Leaflet, baseLayer };
return [map, Leaflet, tileLayer];
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
leaflet.Browser.retina ? "@2x.png" : ".png"
}`,
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: "abcd",
minZoom: 0,
maxZoom: 20,
}
);
+25
View File
@@ -0,0 +1,25 @@
// 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;
};
@@ -0,0 +1 @@
export const webComponentsSupported = "attachShadow" in Element.prototype;
+50
View File
@@ -0,0 +1,50 @@
/**
* 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);
-333
View File
@@ -1,333 +0,0 @@
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 =
'&copy; <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);
};
-58
View File
@@ -1,58 +0,0 @@
/** 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);
}
`;
@@ -1,339 +0,0 @@
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
-98
View File
@@ -1,98 +0,0 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import { getColorByIndex } from "../color/colors";
import { computeDomain } from "../entity/compute_domain";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
/**
* Map colors for entities, by registry creation order per domain, so an
* entity has the same color on every map. Entities without a registry entry
* (e.g. YAML zones) get a color derived from their id.
*/
export const HOME_ZONE_ENTITY_ID = "zone.home";
/** Domains whose entities are colored by creation order */
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
let creationIndex: Record<string, number> = {};
let subscribers = 0;
let unsubscribe: UnsubscribeFunc | undefined;
const listeners = new Set<() => void>();
const rebuildIndex = (entries: EntityRegistryEntry[]) => {
const byDomain: Record<string, EntityRegistryEntry[]> = {};
for (const entry of entries) {
const domain = computeDomain(entry.entity_id);
if (ORDERED_DOMAINS.includes(domain)) {
(byDomain[domain] ??= []).push(entry);
}
}
const index: Record<string, number> = {};
for (const domainEntries of Object.values(byDomain)) {
domainEntries
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
// The home zone has a fixed color and does not take a palette slot
.filter((entry) => entry.entity_id !== HOME_ZONE_ENTITY_ID)
.forEach((entry, i) => {
index[entry.entity_id] = i;
});
}
creationIndex = index;
listeners.forEach((listener) => listener());
};
/** Keeps the creation order current while a map is alive; onChange runs when colors may have changed */
export const subscribeEntityMapColors = (
connection: Connection,
onChange: () => void
): UnsubscribeFunc => {
listeners.add(onChange);
subscribers++;
if (!unsubscribe) {
unsubscribe = subscribeEntityRegistry(connection, rebuildIndex);
}
return () => {
listeners.delete(onChange);
subscribers--;
if (subscribers === 0 && unsubscribe) {
unsubscribe();
unsubscribe = undefined;
creationIndex = {};
}
};
};
// For entities without a registry entry
const hashIndex = (entityId: string): number => {
let hash = 5381;
for (let i = 0; i < entityId.length; i++) {
hash = (hash * 33 + entityId.charCodeAt(i)) % 2147483647;
}
return hash;
};
/** The palette color of an entity on a map */
export const entityMapColor = (
entityId: string,
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex[entityId] ?? hashIndex(entityId),
computedStyles
);
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
export const zoneColor = (
entityId: string,
passive: boolean,
computedStyles: CSSStyleDeclaration
): string => {
if (entityId === HOME_ZONE_ENTITY_ID) {
return computedStyles.getPropertyValue("--primary-color");
}
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return entityMapColor(entityId, computedStyles);
};
-267
View File
@@ -1,267 +0,0 @@
/**
* 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],
];
};
-70
View File
@@ -1,70 +0,0 @@
import { getContrastedColorHex } from "../color/rgb";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Content color contrasting the fill; not every theme color parses
export const contrastingZoneContent = (color: string): string => {
try {
return getContrastedColorHex(color.trim());
} catch {
return "#ffffff";
}
};
export const zoneInitials = (name: string): string =>
name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2);
export const createZoneMarkerElement = (options: {
color: string;
icon?: string;
/** Path for an ha-svg-icon, when there is no icon name */
iconPath?: string;
name: string;
}): HTMLElement => {
const element = document.createElement("div");
element.className = "zone-circle";
element.style.backgroundColor = options.color;
element.style.color = contrastingZoneContent(options.color);
if (options.icon) {
const icon = document.createElement("ha-icon");
icon.setAttribute("icon", options.icon);
element.appendChild(icon);
} else if (options.iconPath) {
const icon = document.createElement("ha-svg-icon");
icon.setAttribute("path", options.iconPath);
element.appendChild(icon);
} else {
const initials = document.createElement("span");
initials.textContent = zoneInitials(options.name);
element.appendChild(initials);
}
return element;
};
/** Styles for the zone marker, included by ha-map */
export const zoneMarkerStyles = `
.zone-circle {
width: ${ZONE_CIRCLE_SIZE}px;
height: ${ZONE_CIRCLE_SIZE}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
.zone-circle.draggable {
cursor: move;
}
`;
+2 -191
View File
@@ -42,7 +42,6 @@ export const updateHistoryState = (patch: Record<string, unknown>) => {
*/
export const replaceCurrentUrl = (url: string) => {
mainWindow.history.replaceState(mainWindow.history.state, "", url);
rememberCurrentEntry();
};
/**
@@ -81,105 +80,6 @@ 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
@@ -191,7 +91,7 @@ const buildHistoryState = (
return { ...state, from };
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
@@ -224,32 +124,12 @@ const performNavigation = 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
@@ -262,9 +142,6 @@ 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());
@@ -275,75 +152,9 @@ 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 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;
}
);
await navigate(fallbackPath || "/", { replace: true });
};
+10 -1
View File
@@ -10,15 +10,18 @@ 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
search: SearchParamsSource,
hash = ""
): MoreInfoUrlData => {
const params =
typeof search === "string"
@@ -32,6 +35,9 @@ export const decodeMoreInfoUrl = (
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
@@ -42,6 +48,9 @@ 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}`;
};
+19
View File
@@ -0,0 +1,19 @@
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;
};
@@ -1,37 +0,0 @@
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;
};
+14 -43
View File
@@ -3,27 +3,6 @@ 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.
@@ -41,29 +20,21 @@ export const sideTooltipPosition: TooltipPositionCallback = (
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
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 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];
};
+18 -79
View File
@@ -10,16 +10,12 @@ interface MeanFrame {
}
interface MinMaxFrame {
// A frame can hold a gap marker before any value lands in it, so the min/max
// slots below only mean something once this is true.
hasValue: boolean;
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
gapPoint: Point | undefined;
}
const SECOND = 1000;
@@ -53,25 +49,6 @@ function snapFrameSize(step: number): number {
return snapped;
}
// y is NaN for a frame seeded by a gap marker, which has no value yet.
function newFrame(
point: Point,
x: number,
y: number,
gapPoint: Point | undefined
): MinMaxFrame {
return {
hasValue: gapPoint === undefined,
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
gapPoint,
};
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -105,10 +82,7 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const rawY = pointData[1] as number | null;
// Number(null) is 0, which would drag the mean towards zero
if (rawY === null) continue;
const y = Number(rawY);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
@@ -146,34 +120,21 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
if (isNaN(x)) continue;
const rawY = pointData[1] as number | null;
if (rawY === null) {
// The chart data modules push a null value to break the line where an
// entity was unavailable. Number(null) is 0, so such a marker must stay
// out of the comparisons below, where it would win the minimum slot
// whenever the readings are positive and discard the frame's real
// minimum. One marker per frame is enough to break the line, and keeping
// them all would blow up the output on series that are mostly null. The
// last one wins: where the break lands only depends on which points it
// sits between, not on its own x.
const gapIndex = Math.floor(x / step);
const gapFrame = frames.get(gapIndex);
if (gapFrame) {
gapFrame.gapPoint = point;
} else {
frames.set(gapIndex, newFrame(point, x, NaN, point));
}
continue;
}
const y = Number(rawY);
if (isNaN(y)) continue;
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, newFrame(point, x, y, undefined));
} else if (frame.hasValue) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
@@ -186,40 +147,18 @@ export function downSampleLineData<
frame.maxX = x;
frame.maxY = y;
}
} else {
// the frame held nothing but a marker so far
frame.hasValue = true;
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
if (frame.hasValue) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
if (frame.gapPoint !== undefined) {
// A marker followed by a value in its own frame is a gap that closed
// within one frame, which is about one device pixel: too narrow to show.
// The kept points are exactly min and max, so comparing against the
// later of the two catches that without any work on the ingest path. A
// marker-only frame compares against its own x and always passes.
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
result.push(frame.gapPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
+3 -20
View File
@@ -362,7 +362,7 @@ export class HaChartBase extends LitElement {
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series || changedProps.has("_isZoomed")) {
if (chartOptions.series) {
this._updateSankeyRoam();
}
}
@@ -654,11 +654,6 @@ 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);
});
@@ -669,7 +664,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 = Math.abs(sankeySeries.zoom - 1) > 1e-6;
const zoomed = sankeySeries.zoom !== 1;
this._isZoomed = zoomed;
if (!zoomed) {
// Reset center when fully zoomed out
@@ -1284,24 +1279,12 @@ export class HaChartBase extends LitElement {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
roam: this._getSankeyRoam(),
roam: this._modifierPressed || this._isTouchDevice ? true : "move",
})),
});
}
}
// 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,7 +328,6 @@ 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,3 +1,4 @@
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";
@@ -12,7 +13,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 { itemTooltipPosition } from "./chart-tooltip-position";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -23,6 +24,7 @@ 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")
@@ -41,6 +43,10 @@ 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;
@@ -63,6 +69,13 @@ 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() {
@@ -70,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -180,13 +193,19 @@ 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")
changedProps.has("_yWidth") ||
widthChanged
) {
this._createOptions();
}
@@ -196,14 +215,22 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? 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",
@@ -227,41 +254,56 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
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,
});
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,
}
return label;
},
hideOverlap: true,
},
: {
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,
},
},
grid: {
top: 10,
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
},
tooltip: {
renderMode: "html",
position: itemTooltipPosition,
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -401,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,6 +79,10 @@ 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;
@@ -227,6 +231,7 @@ 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}
@@ -424,6 +429,12 @@ 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;
}
-1
View File
@@ -446,7 +446,6 @@ 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,
+26 -121
View File
@@ -1,32 +1,13 @@
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, 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.
// 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.
export function computeYAxisFractionDigits(
min: number,
max: number,
@@ -41,7 +22,13 @@ 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;
return getPrecision(nice(range / SPLIT_NUMBER, true));
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);
}
interface YAxisExtentValues {
@@ -57,116 +44,34 @@ const resolveYAxisBound = (
values: YAxisExtentValues
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
// 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.
// 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.
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, 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,
};
};
const { min, max, includeZero, onFractionDigits } = options;
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 bounds = resolveBounds(values);
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
const extentMin = resolvedMin ?? values.min;
const extentMax = resolvedMax ?? values.max;
onFractionDigits(
computeYAxisFractionDigits(
bounds.min ?? values.min - bounds.gap,
bounds.max ?? values.max + bounds.gap,
includeZero
)
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
);
return bounds.min;
return resolvedMin;
},
max: (values) => resolveBounds(values).max,
max: (values) => resolveYAxisBound(max, values),
};
}
+36 -6
View File
@@ -204,17 +204,45 @@ export class HaDataTable extends LitElement {
this._checkedRowsChanged();
}
public selectAll(extraFilter?: (row: DataTableRowData) => boolean): void {
public selectAll(): void {
this._checkedRows = (this._filteredData || [])
.filter(
(data) =>
data.selectable !== false && (!extraFilter || extraFilter(data))
)
.filter((data) => data.selectable !== false)
.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) {
@@ -1207,8 +1235,10 @@ export class HaDataTable extends LitElement {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.mdc-data-table__header-row {
scrollbar-width: none;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.mdc-data-table__cell,
@@ -12,6 +12,7 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -179,15 +180,12 @@ export abstract class HaDeviceAutomationPicker<
(a, idx) => value === `${a.device_id}_${idx}`
);
const described =
automation ?? (this.value?.domain ? this.value : undefined);
const text = described
const text = automation
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
described
automation
)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
@@ -197,24 +195,29 @@ export abstract class HaDeviceAutomationPicker<
};
private async _updateDeviceInfo() {
// Asking a removed device for its automations fails rather than returning
// an empty list.
this._automations = this.deviceId
? (
await this._fetchDeviceAutomations(
this.hass.callWS,
this.deviceId
).catch(() => [] as T[])
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
).sort(sortDeviceAutomations)
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the value.
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
);
}
this._renderEmpty = true;
+47 -71
View File
@@ -1,4 +1,4 @@
import { mdiSwapHorizontal } from "@mdi/js";
import { mdiAlertOutline } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
@@ -22,7 +22,6 @@ import {
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
@@ -105,14 +104,6 @@ export class HaDevicePicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
/**
* The split devices that can actually replace the current value, when the
* caller knows better than this picker. Narrows the replacement candidates,
* so the user is only asked to choose when there is a real choice.
*/
@property({ attribute: false })
public replacementDeviceIds?: string[];
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@@ -196,8 +187,7 @@ export class HaDevicePicker extends LitElement {
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[],
replacementDeviceIds: string[] | undefined
items: (DevicePickerItem | string)[]
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
@@ -213,11 +203,7 @@ export class HaDevicePicker extends LitElement {
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter(
(id) =>
selectableIds.has(id) &&
(!replacementDeviceIds || replacementDeviceIds.includes(id))
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
return { candidates, primaryId: split.primary_id };
}
);
@@ -264,26 +250,26 @@ export class HaDevicePicker extends LitElement {
};
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
// The removed device has no name left to show, so say what happened
// to it instead. The alert below names the replacements. Without a
// replacement, fall back to the normal "not found" display.
if (isReplaced) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
return html`
<ha-svg-icon
slot="start"
.path=${mdiSwapHorizontal}
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
></ha-svg-icon>
<span slot="headline"
>${this.hass.localize(
"ui.components.device-picker.device_replaced"
)}</span
>
<span slot="headline">${replacementName}</span>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
@@ -413,23 +399,31 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems(),
this.replacementDeviceIds
this._getItems()
)
: undefined;
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
canReplace
replacementName
);
return html`
<ha-generic-picker
.noUnknownState=${canReplace}
.hass=${this.hass}
.autofocus=${this.autofocus}
.disabled=${this.disabled}
@@ -449,9 +443,14 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -465,52 +464,30 @@ export class HaDevicePicker extends LitElement {
}) {
const { candidates } = replacement;
// The split devices all inherit the composite's name, so the integration is
// what tells them apart.
const replacementDevice =
candidates.length === 1 ? this.hass.devices[candidates[0]] : undefined;
const replacementName = replacementDevice
? computeDeviceName(replacementDevice)
: undefined;
const replacementDomain = replacementDevice?.primary_config_entry
? this._configEntryLookup[replacementDevice.primary_config_entry]?.domain
: undefined;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName && replacementDomain
replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one_integration",
{
device: replacementName,
integration: domainToName(
this.hass.localize,
replacementDomain
),
}
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
: replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
variant="warning"
@click=${this._handleReplace}
>
${
candidates.length === 1
? this.hass.localize("ui.components.device-picker.replace_update")
: this.hass.localize("ui.components.device-picker.replace_choose")
}
${this.hass.localize("ui.components.device-picker.replace_device")}
</ha-button>
</ha-alert>
`;
@@ -521,8 +498,7 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems(),
this.replacementDeviceIds
this._getItems()
);
if (!replacement?.candidates.length) {
return;
+1 -1
View File
@@ -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";
+5
View File
@@ -293,7 +293,12 @@ 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;
}
+1 -1
View File
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
static styles = css`
.issue-type {
position: relative;
padding: var(--ha-alert-padding, 8px);
padding: 8px;
display: flex;
}
.icon {
+13
View File
@@ -1061,18 +1061,31 @@ ${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);
}
}
+1
View File
@@ -70,6 +70,7 @@ 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);
+11
View File
@@ -432,6 +432,13 @@ 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(
@@ -463,6 +470,10 @@ 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)
-6
View File
@@ -5,7 +5,6 @@ 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 {
@@ -150,7 +149,6 @@ export class HaCameraStream extends LitElement {
objectFit: this.fitMode,
})}
alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
@load=${this._handleImageLoad}
/>`;
}
@@ -216,10 +214,6 @@ export class HaCameraStream extends LitElement {
}
}
private _handleImageLoad() {
fireEvent(this, "load");
}
private _handleHlsStreams(ev: CustomEvent) {
this._hlsStreams = ev.detail;
}
+1
View File
@@ -13,6 +13,7 @@ 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;
+5 -12
View File
@@ -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(
`[data-index="${index}"]`
`#option-${this.options![index].value}`
) as HTMLElement;
option?.focus();
});
@@ -144,11 +144,7 @@ export class HaControlSelect extends LitElement {
this.options,
(option) => option.value,
(option, index) =>
this._renderOption(
option,
index,
index === this._tabbableIndex
)
this._renderOption(option, index === this._tabbableIndex)
)
: nothing
}
@@ -163,17 +159,12 @@ export class HaControlSelect extends LitElement {
return selectedIndex === -1 ? 0 : selectedIndex;
}
private _renderOption(
option: ControlSelectOption,
index: number,
tabbable: boolean
) {
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
const isSelected = this.value === option.value;
return html`
<div
id=${`option-${option.value}`}
data-index=${index}
class=${classMap({
option: true,
selected: isSelected,
@@ -332,6 +323,8 @@ export class HaControlSelect extends LitElement {
.option .content span {
display: block;
width: 100%;
-webkit-hyphens: auto;
-moz-hyphens: auto;
hyphens: auto;
}
:host([vertical]) {
-216
View File
@@ -1,216 +0,0 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
import { computeDeviceClassName } from "../data/entity/device_class";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
export const getDeviceClassOptions = (
domain: string,
localize: LocalizeFunc
): PickerComboBoxItem[] =>
(DOMAIN_DEVICE_CLASSES[domain] ?? []).map((deviceClass) => {
const primary = computeDeviceClassName(localize, domain, deviceClass);
return { id: deviceClass, primary, sorting_label: primary };
});
@customElement("ha-device-class-picker")
export class HaDeviceClassPicker extends LitElement {
@property() public domain?: string;
@property({ attribute: false }) public value?: string | string[];
@property({ type: Boolean }) public multiple = false;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
private _loadedDomain?: string;
protected willUpdate() {
if (!this.domain || !this._i18n || this._loadedDomain === this.domain) {
return;
}
this._loadedDomain = this.domain;
this._i18n.loadBackendTranslation("entity_component", this.domain);
}
private get _value(): string[] {
return this.value ? ensureArray(this.value) : [];
}
private _deviceClassName(deviceClass: string): string {
return this._i18n && this.domain
? computeDeviceClassName(this._i18n.localize, this.domain, deviceClass)
: deviceClass;
}
private _options = memoizeOne(
(
domain: string | undefined,
localize: LocalizeFunc | undefined
): PickerComboBoxItem[] =>
domain && localize ? getDeviceClassOptions(domain, localize) : []
);
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(this.domain, this._i18n?.localize);
return this.multiple
? this._availableOptions(options, this._value)
: options;
};
private _valueRenderer = (value: string) =>
html`<span slot="headline">${this._deviceClassName(value)}</span>`;
private _notFoundLabel = (search: string) => {
const term = html`<b>'${search}'</b>`;
return this._i18n
? this._i18n.localize("ui.components.device-class-picker.no_match", {
term,
})
: html`No device classes found for ${term}`;
};
protected render() {
const localize = this._i18n?.localize;
const emptyLabel = localize?.(
"ui.components.device-class-picker.no_device_classes"
);
if (this.multiple) {
const value = this._value;
return html`
${
value.length
? html`
<ha-chip-set>
${repeat(
value,
(deviceClass) => deviceClass,
(deviceClass) => {
const label = this._deviceClassName(deviceClass);
return html`
<ha-input-chip
.item=${deviceClass}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
<ha-generic-picker
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.addButtonLabel=${
this.label ?? localize?.("ui.components.device-class-picker.add")
}
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
}
return html`
<ha-generic-picker
.label=${
this.label ??
localize?.("ui.components.device-class-picker.device_class")
}
.value=${this.value as string | undefined}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || undefined });
}
private _itemAdded(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
const deviceClass = ev.detail.value;
if (!deviceClass || this._value.includes(deviceClass)) {
return;
}
this._setValue([...this._value, deviceClass]);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const deviceClass = (ev.currentTarget as HTMLElement & { item: string })
.item;
this._setValue(this._value.filter((item) => item !== deviceClass));
}
private _setValue(value: string[]) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
static styles = css`
:host {
display: block;
}
ha-generic-picker {
display: block;
width: 100%;
}
ha-chip-set {
padding: 8px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-class-picker": HaDeviceClassPicker;
}
}
+8
View File
@@ -393,6 +393,10 @@ 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);
@@ -425,6 +429,10 @@ 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)
-4
View File
@@ -95,10 +95,6 @@ export class HaDrawer extends LitElement {
}
private _handleAfterHide(ev: Event) {
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
if (ev.target !== ev.currentTarget) {
return;
}
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
+5
View File
@@ -63,6 +63,11 @@ 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,13 +1,6 @@
import {
getSelectorInitialValue,
getSelectorInitialValueOrUndefined,
} from "./get-selector-initial-value";
import type { Selector } from "../../data/selector";
import type { HaFormData, HaFormSchema } from "./types";
interface ComputeInitialHaFormDataOptions {
skipUnsupportedSelectors?: boolean;
}
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
@@ -25,8 +18,7 @@ const setDefaultValue = (
};
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[],
options?: ComputeInitialHaFormDataOptions
schema: HaFormSchema[] | readonly HaFormSchema[]
): Record<string, any> => {
const data = {};
schema.forEach((field) => {
@@ -41,7 +33,7 @@ export const computeInitialHaFormData = (
} else if ("default" in field) {
data[field.name] = setDefaultValue(field, field.default);
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema, options);
const expandableData = computeInitialHaFormData(field.schema);
if (field.required || Object.keys(expandableData).length) {
// Only add expandable data if it's required or any of its children have initial values.
data[field.name] = expandableData;
@@ -70,11 +62,104 @@ export const computeInitialHaFormData = (
seconds: 0,
};
} else if ("selector" in field) {
const initialValue = options?.skipUnsupportedSelectors
? getSelectorInitialValueOrUndefined(field.selector)
: getSelectorInitialValue(field.selector);
if (initialValue !== undefined) {
data[field.name] = initialValue;
const selector: Selector = field.selector;
if ("device" in selector) {
data[field.name] = selector.device?.multiple ? [] : "";
} else if ("entity" in selector) {
data[field.name] = selector.entity?.multiple ? [] : "";
} else if ("area" in selector) {
data[field.name] = selector.area?.multiple ? [] : "";
} else if ("label" in selector) {
data[field.name] = selector.label?.multiple ? [] : "";
} else if ("boolean" in selector) {
data[field.name] = false;
} else if (
"addon" in selector ||
"attribute" in selector ||
"file" in selector ||
"icon" in selector ||
"serial_port" in selector ||
"template" in selector ||
"text" in selector ||
"theme" in selector ||
"object" in selector
) {
data[field.name] = "";
} else if ("number" in selector) {
data[field.name] = selector.number?.min ?? 0;
} else if ("select" in selector) {
if (selector.select?.options.length) {
const firstOption = selector.select.options[0];
const val =
typeof firstOption === "string" ? firstOption : firstOption.value;
data[field.name] = selector.select.multiple ? [val] : val;
}
} else if ("country" in selector) {
if (selector.country?.countries?.length) {
data[field.name] = selector.country.countries[0];
}
} else if ("language" in selector) {
if (selector.language?.languages?.length) {
data[field.name] = selector.language.languages[0];
}
} else if ("duration" in selector) {
data[field.name] = {
hours: 0,
minutes: 0,
seconds: 0,
};
} else if ("time" in selector) {
data[field.name] = "00:00:00";
} else if ("date" in selector || "datetime" in selector) {
const now = new Date().toISOString().slice(0, 10);
data[field.name] = `${now}T00:00:00`;
} else if ("color_rgb" in selector) {
data[field.name] = [0, 0, 0];
} else if ("color_temp" in selector) {
data[field.name] = selector.color_temp?.min_mireds ?? 153;
} else if (
"action" in selector ||
"trigger" in selector ||
"condition" in selector
) {
data[field.name] = [];
} else if ("media" in selector || "target" in selector) {
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
);
}
}
});
@@ -1,90 +1,25 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorFallbackValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_FALLBACK_VALUES = {
action: undefined,
addon: undefined,
automation_behavior: undefined,
app: undefined,
area: undefined,
areas_display: undefined,
attribute: undefined,
assist_pipeline: undefined,
boolean: () => false,
choose: undefined,
color_rgb: undefined,
condition: undefined,
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: undefined,
date: undefined,
datetime: undefined,
device: undefined,
device_class: undefined,
duration: undefined,
entity: undefined,
entity_name: undefined,
statistic: undefined,
file: undefined,
floor: undefined,
label: undefined,
language: undefined,
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
selector: undefined,
serial_port: undefined,
state: undefined,
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;
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
return fallbackValue?.(selector as never);
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};

Some files were not shown because too many files have changed in this diff Show More