mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 11:58:45 +00:00
Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca5f1fa70 | ||
|
|
b29b0bd083 | ||
|
|
ae8aa3813b | ||
|
|
770caf62f7 | ||
|
|
8fed3ac0c8 | ||
|
|
98f5bdd703 | ||
|
|
8e13ba872d | ||
|
|
37433c787f | ||
|
|
dc7ee8dc5a | ||
|
|
ddc99501af | ||
|
|
5ec87269dc | ||
|
|
53cf986491 | ||
|
|
0388cb8cd0 | ||
|
|
cfefa39b84 | ||
|
|
5a907614af | ||
|
|
c325392e79 | ||
|
|
241b8c5771 | ||
|
|
06e55141f5 | ||
|
|
f6751a8c44 | ||
|
|
1fb138337a | ||
|
|
50df620a01 | ||
|
|
e581172474 | ||
|
|
59f16eeca2 | ||
|
|
1cda8ee42d | ||
|
|
87a4a70352 | ||
|
|
046cf4de14 | ||
|
|
d0d5b93106 | ||
|
|
3f2c0dae1b | ||
|
|
5dea28504e | ||
|
|
8e1c4e93cf | ||
|
|
5513abbe8f | ||
|
|
a9862a8f7c | ||
|
|
d23314f4ff | ||
|
|
fcc5b381d3 | ||
|
|
564205a3f3 | ||
|
|
86fb936d14 | ||
|
|
497b7fce49 | ||
|
|
251aac7e2e | ||
|
|
8d2106ccc8 | ||
|
|
3de2637da7 | ||
|
|
36d048c8ab | ||
|
|
4d7d5231d6 | ||
|
|
b5709a56b0 | ||
|
|
1b834303d0 | ||
|
|
b455a34998 | ||
|
|
d84ef6b1bb | ||
|
|
10ae5c1dd6 | ||
|
|
74967ef6ee | ||
|
|
46de23727f | ||
|
|
dc5d9f48bb | ||
|
|
3d3cdabfe7 | ||
|
|
4b1f12c0ef | ||
|
|
a9d7712fb2 | ||
|
|
a03efc1c5d | ||
|
|
9f7c353ad0 | ||
|
|
350fae4107 | ||
|
|
e09c4084b7 | ||
|
|
946722daa2 | ||
|
|
3b038d5372 | ||
|
|
6b7d387175 | ||
|
|
8a4202ea19 | ||
|
|
2eaf1112f2 | ||
|
|
d30ff07394 | ||
|
|
5616b52979 | ||
|
|
4219288701 | ||
|
|
09b221cf45 | ||
|
|
89a467cac7 | ||
|
|
c29b693038 | ||
|
|
7ab178d045 | ||
|
|
538523c58b | ||
|
|
136cf0c8bf | ||
|
|
920c858056 | ||
|
|
3d2a4bfa04 | ||
|
|
e2c9b2cb79 | ||
|
|
d38ee185bf | ||
|
|
7e10f50419 | ||
|
|
070fd96d6c | ||
|
|
156840b6ff | ||
|
|
5ff7a05ec5 | ||
|
|
c3643555ab | ||
|
|
ae2c296272 | ||
|
|
1db1c8024e | ||
|
|
2f003ca999 | ||
|
|
b1d8a21aaa | ||
|
|
cde2e66229 | ||
|
|
71d5500ed8 | ||
|
|
462321a3c8 | ||
|
|
afc0c36dd9 | ||
|
|
7f86074e07 | ||
|
|
4b9e47079c | ||
|
|
e3b9c6e143 | ||
|
|
221f184cb1 | ||
|
|
27cbbdb83f | ||
|
|
337e88560e | ||
|
|
9c96c56558 | ||
|
|
27e0c4492c | ||
|
|
80889c3cea | ||
|
|
cbbaf51e75 | ||
|
|
9778a54305 | ||
|
|
b9125537c0 | ||
|
|
fd6017a963 | ||
|
|
e0f5d6d6b3 | ||
|
|
193ab1b424 | ||
|
|
4a3b6db411 | ||
|
|
46271d0435 | ||
|
|
d4bc02c86b | ||
|
|
a12ae9a8ed | ||
|
|
6095787294 | ||
|
|
127702b96f | ||
|
|
56a6c5d9fb | ||
|
|
3b686dc3b9 | ||
|
|
c5e8dea750 | ||
|
|
29cd46eb9e | ||
|
|
92b3081896 | ||
|
|
527319ab5e | ||
|
|
8d3877c388 | ||
|
|
66b9fbd4bf |
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -34,6 +34,14 @@ In production, the following responsibilities are added:
|
||||
|
||||
- Minify HTML
|
||||
- Bundle multiple imports so that the browser can fetch less files
|
||||
- Generate a second version that is ES5 compatible
|
||||
- Generate a second version that is compatible with older browsers (legacy build)
|
||||
|
||||
Configuration for all these steps are specified in [bundle.js](bundle.js).
|
||||
|
||||
## Auditing browser support changes
|
||||
|
||||
`node build-scripts/list-plugins-and-polyfills.js` prints, per browserslist
|
||||
environment (modern/legacy), the Babel transforms preset-env enables and the
|
||||
Core-JS polyfills that may be injected — as collapsible markdown ready to
|
||||
paste into a PR. Use it to show the bundle impact when changing
|
||||
`.browserslistrc` or the Babel configuration.
|
||||
|
||||
@@ -121,7 +121,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
ignoreModuleNotFound: true,
|
||||
},
|
||||
],
|
||||
// Import helpers and regenerator from runtime package.
|
||||
// Import helpers from runtime package.
|
||||
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
|
||||
// corejs3 polyfill provider above otherwise redirects them to the
|
||||
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
|
||||
@@ -155,8 +155,6 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
"proxy-polyfill",
|
||||
"unfetch",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
|
||||
16: [11, 0, 0],
|
||||
17: [12, 0, 0],
|
||||
18: [13, 0, 0],
|
||||
26: [26, 0, 0],
|
||||
26: [14, 6, 0],
|
||||
};
|
||||
|
||||
const getCommonTemplateVars = () => {
|
||||
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
|
||||
...htmlMinifierOptions,
|
||||
conservativeCollapse: false,
|
||||
minifyJS: terserOptions({
|
||||
latestBuild: false, // Shared scripts should be ES5
|
||||
latestBuild: false, // Shared scripts must satisfy the legacy targets
|
||||
isTestBuild: true, // Don't need source maps
|
||||
}),
|
||||
}).then((wrapped) =>
|
||||
|
||||
@@ -42,37 +42,6 @@ function copyMdiIcons(staticDir) {
|
||||
fs.copySync(polyPath("build/mdi"), staticPath("mdi"));
|
||||
}
|
||||
|
||||
function copyPolyfills(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
|
||||
// For custom panels using ES5 builds that don't use Babel 7+
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
|
||||
// Web Component polyfills and adapters
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js.map"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
// Lit polyfill support
|
||||
fs.copySync(
|
||||
npmPath("lit/polyfill-support.js"),
|
||||
path.join(staticPath("polyfills/"), "lit-polyfill-support.js")
|
||||
);
|
||||
|
||||
// dialog-polyfill css
|
||||
copyFileDir(
|
||||
npmPath("dialog-polyfill/dialog-polyfill.css"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
}
|
||||
|
||||
function copyFonts(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Local fonts
|
||||
@@ -141,7 +110,6 @@ 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);
|
||||
@@ -163,7 +131,6 @@ gulp.task("copy-static-demo", async () => {
|
||||
);
|
||||
// Copy demo static files
|
||||
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
|
||||
copyPolyfills(paths.demo_output_static);
|
||||
await copyMapPanel(paths.demo_output_static);
|
||||
copyFonts(paths.demo_output_static);
|
||||
copyTranslations(paths.demo_output_static);
|
||||
@@ -176,7 +143,6 @@ gulp.task("copy-static-cast", async () => {
|
||||
fs.copySync(polyPath("public/static"), paths.cast_output_static);
|
||||
// Copy cast static files
|
||||
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
|
||||
copyPolyfills(paths.cast_output_static);
|
||||
await copyMapPanel(paths.cast_output_static);
|
||||
copyFonts(paths.cast_output_static);
|
||||
copyTranslations(paths.cast_output_static);
|
||||
@@ -223,7 +189,6 @@ gulp.task("copy-static-e2e-test-app", async () => {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
await copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
|
||||
@@ -25,7 +25,7 @@ gulp.task("gen-sensor-entity-constants", async () => {
|
||||
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
|
||||
const deviceClassUnits = data.device_class_units ?? {};
|
||||
const convertibleClassUnits = data.convertible_units ?? {};
|
||||
const stateClasses = [...(data.state_classes ?? [])].sort();
|
||||
const stateClasses = data.state_classes ?? [];
|
||||
const stateClassUnits = data.state_class_units ?? {};
|
||||
if (
|
||||
!numericDeviceClasses.length ||
|
||||
|
||||
@@ -1,38 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
|
||||
|
||||
import { version as babelVersion } from "@babel/core";
|
||||
import presetEnv from "@babel/preset-env";
|
||||
import compilationTargets from "@babel/helper-compilation-targets";
|
||||
import { transformSync } from "@babel/core";
|
||||
import compilationTargets, {
|
||||
getInclusionReasons,
|
||||
} from "@babel/helper-compilation-targets";
|
||||
import coreJSCompat from "core-js-compat";
|
||||
import { logPlugin } from "@babel/preset-env/lib/debug.js";
|
||||
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
|
||||
import { babelOptions } from "./bundle.cjs";
|
||||
|
||||
const detailsOpen = (heading) =>
|
||||
`<details>\n<summary><h4>${heading}</h4></summary>\n`;
|
||||
const detailsClose = "</details>\n";
|
||||
|
||||
const dummyAPI = {
|
||||
version: babelVersion,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
assertVersion: () => {},
|
||||
caller: (callback) =>
|
||||
callback({
|
||||
name: "Dummy Bundler",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
}),
|
||||
targets: () => ({}),
|
||||
// Copied from @babel/preset-env's internal `logPlugin`, which Babel 8 no
|
||||
// longer exposes (the package rolls up into lib/index.js and exports nothing
|
||||
// but the preset). Prints an item with the targets that require it.
|
||||
const logPlugin = (item, targetVersions, list) => {
|
||||
const filteredList = getInclusionReasons(item, targetVersions, list);
|
||||
const support = list[item];
|
||||
if (!support) {
|
||||
console.log(` ${item}`);
|
||||
return;
|
||||
}
|
||||
let formattedTargets = `{`;
|
||||
let first = true;
|
||||
for (const target of Object.keys(filteredList)) {
|
||||
if (!first) formattedTargets += `,`;
|
||||
first = false;
|
||||
formattedTargets += ` ${target}`;
|
||||
if (support[target]) formattedTargets += ` < ${support[target]}`;
|
||||
}
|
||||
formattedTargets += ` }`;
|
||||
console.log(` ${item} ${formattedTargets}`);
|
||||
};
|
||||
|
||||
// Copied from babel-plugin-polyfill-corejs3's generated
|
||||
// corejs3ShippedProposalsList, which v1 no longer exposes (it is inlined in
|
||||
// the package's rolled-up bundle).
|
||||
const shippedProposalsList = new Set([
|
||||
"esnext.array.group",
|
||||
"esnext.array.group-to-map",
|
||||
"esnext.iterator.zip",
|
||||
"esnext.iterator.zip-keyed",
|
||||
"esnext.symbol.metadata",
|
||||
]);
|
||||
|
||||
// Generate filter function based on proposal/method inputs
|
||||
// Copied and adapted from babel-plugin-polyfill-corejs3/esm/index.mjs
|
||||
const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
if (proposals || method === "entry-global") return true;
|
||||
if (shippedProposals && shippedPolyfills.default.has(name)) {
|
||||
if (shippedProposals && shippedProposalsList.has(name)) {
|
||||
return true;
|
||||
}
|
||||
if (name.startsWith("esnext.")) {
|
||||
@@ -47,7 +64,9 @@ const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
for (const buildType of ["Modern", "Legacy"]) {
|
||||
const browserslistEnv = buildType.toLowerCase();
|
||||
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
|
||||
const presetEnvOpts = babelOpts.presets[0][1];
|
||||
const presetEnvOpts = babelOpts.presets.find(
|
||||
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
|
||||
)?.[1];
|
||||
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
|
||||
// removed preset-env's `useBuiltIns`), so read its options here.
|
||||
const corejsOpts = babelOpts.plugins.find(
|
||||
@@ -55,22 +74,38 @@ for (const buildType of ["Modern", "Legacy"]) {
|
||||
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
|
||||
)?.[1];
|
||||
|
||||
// Invoking preset-env in debug mode will log the included plugins
|
||||
// Transforming an empty file with preset-env in debug mode logs the included
|
||||
// plugins. The caller declares the same capabilities babel-loader does, so
|
||||
// plugins gated on bundler support (e.g. transform-export-namespace-from)
|
||||
// match the build.
|
||||
presetEnvOpts.debug = true;
|
||||
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
|
||||
presetEnv.default(dummyAPI, {
|
||||
...presetEnvOpts,
|
||||
browserslistEnv,
|
||||
debug: true,
|
||||
transformSync("", {
|
||||
...babelOpts,
|
||||
configFile: false,
|
||||
filename: "audit.js",
|
||||
caller: {
|
||||
name: "list-plugins-and-polyfills",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
},
|
||||
});
|
||||
console.log(detailsClose);
|
||||
|
||||
// Manually log the Core-JS polyfills using the same technique
|
||||
if (corejsOpts) {
|
||||
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
|
||||
const targets = compilationTargets.default(babelOpts?.targets, {
|
||||
const targets = compilationTargets(babelOpts.targets, {
|
||||
browserslistEnv,
|
||||
});
|
||||
const polyfillList = coreJSCompat({ targets }).list.filter(
|
||||
// `version` limits the list to modules the installed core-js ships,
|
||||
// mirroring the provider's own filtering.
|
||||
const polyfillList = coreJSCompat({
|
||||
targets,
|
||||
version: corejsOpts.version,
|
||||
}).list.filter(
|
||||
polyfillFilter(
|
||||
corejsOpts.method,
|
||||
corejsOpts.proposals,
|
||||
|
||||
@@ -345,10 +345,7 @@ const createRspackConfig = ({
|
||||
"lit/directives/join$": "lit/directives/join.js",
|
||||
"lit/directives/repeat$": "lit/directives/repeat.js",
|
||||
"lit/directives/live$": "lit/directives/live.js",
|
||||
"lit/directives/keyed$": latestBuild
|
||||
? "lit/directives/keyed.js"
|
||||
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
|
||||
"lit/polyfill-support$": "lit/polyfill-support.js",
|
||||
"lit/directives/keyed$": "lit/directives/keyed.js",
|
||||
"@lit-labs/virtualizer/layouts/grid":
|
||||
"@lit-labs/virtualizer/layouts/grid.js",
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
saveTokens,
|
||||
} from "../../../../src/common/auth/token_storage";
|
||||
import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-icon";
|
||||
import "../../../../src/components/ha-list";
|
||||
@@ -197,8 +196,7 @@ class HcCast extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
toggleAttribute(
|
||||
this,
|
||||
this.toggleAttribute(
|
||||
"hide-icons",
|
||||
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { connectivityEntities } from "../stubs/connectivity/fixtures";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
@@ -53,6 +54,9 @@ export const setDemoConfig = async (
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
// Replaced the whole state map above, so the entities that do not belong to a
|
||||
// demo config have to be added back.
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Let the new registries and entities reach the dashboard before saving the
|
||||
// config, so dashboard strategies generate against them
|
||||
|
||||
+11
-1
@@ -7,6 +7,12 @@ import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import {
|
||||
connectivityCommands,
|
||||
connectivityComponents,
|
||||
connectivityEntities,
|
||||
connectivityEntityRegistryEntries,
|
||||
} from "./stubs/connectivity/fixtures";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
@@ -60,6 +66,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
...connectivityCommands,
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -87,6 +94,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
...connectivityComponents,
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -99,7 +107,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
mockLovelace(hass, localizePromise);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass);
|
||||
mockTranslations(hass, localizePromise);
|
||||
mockHistory(hass);
|
||||
mockRecorder(hass);
|
||||
mockTodo(hass);
|
||||
@@ -172,9 +180,11 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
...connectivityEntityRegistryEntries,
|
||||
]);
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Once config is loaded AND localize, set registries, entities and theme.
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mockBlueprint } from "./blueprint";
|
||||
import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockConnectivity } from "./connectivity";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
@@ -26,6 +27,7 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockCloud(hass);
|
||||
mockConfig(hass);
|
||||
mockConfigEntries(hass);
|
||||
mockConnectivity(hass);
|
||||
mockDeviceAutomation(hass);
|
||||
mockEntitySources(hass);
|
||||
mockBlueprint(hass);
|
||||
@@ -43,4 +45,10 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
hass.mockWS("llm/api/list", () => ({
|
||||
apis: [
|
||||
{ id: "assist", name: "Assist" },
|
||||
{ id: "music_assistant", name: "Music Assistant" },
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
|
||||
import type { IntegrationType } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityConfigEntries } from "./connectivity/fixtures";
|
||||
|
||||
const baseEntry = {
|
||||
source: "user",
|
||||
@@ -80,6 +81,17 @@ export const demoConfigEntries: {
|
||||
title: "Comfort level",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-mcp-server",
|
||||
domain: "mcp_server",
|
||||
title: "Assist, Music Assistant",
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
...connectivityConfigEntries,
|
||||
];
|
||||
|
||||
const filterEntries = (filters?: {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import { configEntry, device } from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const LOCAL_SOURCE = "00:1A:7D:DA:71:11";
|
||||
export const PROXY_SOURCE = "E8:DB:84:A1:C2:30";
|
||||
export const SHED_SOURCE = "A4:CF:12:9B:44:70";
|
||||
|
||||
const ADAPTER_ENTRY_ID = "mock-bluetooth";
|
||||
const PROXY_LIVING_ENTRY_ID = "mock-bluetooth-proxy-living";
|
||||
const PROXY_SHED_ENTRY_ID = "mock-bluetooth-proxy-shed";
|
||||
|
||||
export const bluetoothFixtures: ConnectivityFixtures = {
|
||||
components: ["bluetooth"],
|
||||
commands: ["bluetooth/"],
|
||||
manifests: [manifest("bluetooth", "Bluetooth", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
ADAPTER_ENTRY_ID,
|
||||
"bluetooth",
|
||||
`hci0 (${LOCAL_SOURCE})`,
|
||||
{ source: "usb", supports_options: true }
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
PROXY_LIVING_ENTRY_ID,
|
||||
"bluetooth",
|
||||
"Living room proxy",
|
||||
{ source: "esphome" }
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(PROXY_SHED_ENTRY_ID, "bluetooth", "Shed proxy", {
|
||||
source: "esphome",
|
||||
}),
|
||||
},
|
||||
],
|
||||
// Adapters and proxies are matched to their scanner by the bluetooth
|
||||
// connection tuple, see ./mock.
|
||||
devices: [
|
||||
device(
|
||||
"bluetooth-hci0",
|
||||
"hci0",
|
||||
"Home Assistant",
|
||||
"Home Assistant Green",
|
||||
ADAPTER_ENTRY_ID,
|
||||
{ connections: [["bluetooth", LOCAL_SOURCE]] }
|
||||
),
|
||||
device(
|
||||
"bluetooth-proxy-living",
|
||||
"Living room proxy",
|
||||
"Espressif",
|
||||
"ESP32-C3",
|
||||
PROXY_LIVING_ENTRY_ID,
|
||||
{ area_id: "living_room", connections: [["bluetooth", PROXY_SOURCE]] }
|
||||
),
|
||||
device(
|
||||
"bluetooth-proxy-shed",
|
||||
"Shed proxy",
|
||||
"Espressif",
|
||||
"ESP32",
|
||||
PROXY_SHED_ENTRY_ID,
|
||||
{ connections: [["bluetooth", SHED_SOURCE]] }
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
BluetoothAllocationsData,
|
||||
BluetoothDeviceData,
|
||||
BluetoothScannerDetails,
|
||||
BluetoothScannerState,
|
||||
} from "../../../../../src/data/bluetooth";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
import { LOCAL_SOURCE, PROXY_SOURCE, SHED_SOURCE } from "./fixtures";
|
||||
|
||||
const SCANNERS: BluetoothScannerDetails[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
connectable: true,
|
||||
name: "hci0 (Home Assistant Green)",
|
||||
adapter: "hci0",
|
||||
scanner_type: "usb",
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
connectable: true,
|
||||
name: "Living room proxy",
|
||||
adapter: "esp32",
|
||||
scanner_type: "remote",
|
||||
},
|
||||
{
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
name: "Shed proxy",
|
||||
adapter: "esp32",
|
||||
scanner_type: "remote",
|
||||
},
|
||||
];
|
||||
|
||||
const SCANNER_STATES: BluetoothScannerState[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
adapter: "hci0",
|
||||
current_mode: "active",
|
||||
requested_mode: "active",
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
adapter: "esp32",
|
||||
current_mode: "active",
|
||||
requested_mode: "active",
|
||||
},
|
||||
{
|
||||
// A proxy configured for passive scanning: it reports advertisements but
|
||||
// cannot connect, matching its `connectable: false` scanner details.
|
||||
source: SHED_SOURCE,
|
||||
adapter: "esp32",
|
||||
current_mode: "passive",
|
||||
requested_mode: "passive",
|
||||
},
|
||||
];
|
||||
|
||||
const ALLOCATIONS: BluetoothAllocationsData[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
slots: 5,
|
||||
free: 3,
|
||||
allocated: ["A4:C1:38:11:22:33", "E7:2E:00:B1:9A:1C"],
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
slots: 3,
|
||||
free: 2,
|
||||
allocated: ["FC:58:FA:12:34:56"],
|
||||
},
|
||||
];
|
||||
|
||||
interface DemoAdvertisement {
|
||||
address: string;
|
||||
name: string;
|
||||
rssi: number;
|
||||
source: string;
|
||||
connectable?: boolean;
|
||||
tx_power?: number;
|
||||
manufacturer_data?: Record<number, string>;
|
||||
service_data?: Record<string, string>;
|
||||
service_uuids?: string[];
|
||||
}
|
||||
|
||||
const ADVERTISEMENTS: DemoAdvertisement[] = [
|
||||
{
|
||||
address: "A4:C1:38:11:22:33",
|
||||
name: "Govee H5075",
|
||||
rssi: -58,
|
||||
source: LOCAL_SOURCE,
|
||||
manufacturer_data: { 60552: "000104a10b64" },
|
||||
service_uuids: ["0000ec88-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "E7:2E:00:B1:9A:1C",
|
||||
name: "SwitchBot Meter",
|
||||
rssi: -71,
|
||||
source: LOCAL_SOURCE,
|
||||
service_data: { "0000fd3d-0000-1000-8000-00805f9b34fb": "5400648c14" },
|
||||
service_uuids: ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
|
||||
},
|
||||
{
|
||||
address: "FC:58:FA:12:34:56",
|
||||
name: "Xiaomi LYWSD03MMC",
|
||||
rssi: -64,
|
||||
source: PROXY_SOURCE,
|
||||
service_data: { "0000fe95-0000-1000-8000-00805f9b34fb": "3058590e" },
|
||||
},
|
||||
{
|
||||
address: "C4:7C:8D:6A:5B:20",
|
||||
name: "Flower care",
|
||||
rssi: -88,
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
service_uuids: ["0000fe95-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "D0:36:9A:7F:11:80",
|
||||
name: "Tile Mate",
|
||||
rssi: -79,
|
||||
source: PROXY_SOURCE,
|
||||
connectable: false,
|
||||
service_uuids: ["0000feed-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "5C:C7:C1:04:9E:2A",
|
||||
name: "Nut Find 3",
|
||||
rssi: -93,
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const buildAdvertisement = (
|
||||
advertisement: DemoAdvertisement
|
||||
): BluetoothDeviceData => ({
|
||||
address: advertisement.address,
|
||||
name: advertisement.name,
|
||||
rssi: advertisement.rssi,
|
||||
source: advertisement.source,
|
||||
connectable: advertisement.connectable ?? true,
|
||||
manufacturer_data: advertisement.manufacturer_data ?? {},
|
||||
service_data: advertisement.service_data ?? {},
|
||||
service_uuids: advertisement.service_uuids ?? [],
|
||||
tx_power: advertisement.tx_power ?? -59,
|
||||
time: Date.now() / 1000,
|
||||
raw: null,
|
||||
});
|
||||
|
||||
// Nudge the signal strength a little on every tick so the monitors and the
|
||||
// network map look alive without the rows jumping around.
|
||||
const jitter = (rssi: number) =>
|
||||
Math.max(-99, Math.min(-30, rssi + Math.round(Math.random() * 4) - 2));
|
||||
|
||||
export const mockBluetooth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("bluetooth/subscribe_scanner_details", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.({ add: SCANNERS }))
|
||||
);
|
||||
|
||||
hass.mockWS("bluetooth/subscribe_scanner_state", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => SCANNER_STATES.forEach((state) => onChange?.(state)))
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"bluetooth/subscribe_connection_allocations",
|
||||
(msg: { config_entry_id?: string }, _hass, onChange) =>
|
||||
emitInitial(() =>
|
||||
onChange?.(
|
||||
msg.config_entry_id
|
||||
? ALLOCATIONS.filter((a) => a.source === LOCAL_SOURCE)
|
||||
: ALLOCATIONS
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
hass.mockWS("bluetooth/subscribe_advertisements", (_msg, _hass, onChange) => {
|
||||
let advertisements = ADVERTISEMENTS;
|
||||
const stopInitial = emitInitial(() =>
|
||||
onChange?.({ add: advertisements.map(buildAdvertisement) })
|
||||
);
|
||||
const interval = window.setInterval(() => {
|
||||
advertisements = advertisements.map((advertisement) => ({
|
||||
...advertisement,
|
||||
rssi: jitter(advertisement.rssi),
|
||||
}));
|
||||
onChange?.({ change: advertisements.map(buildAdvertisement) });
|
||||
}, 5000);
|
||||
return () => {
|
||||
stopInitial();
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { bluetoothFixtures } from "./bluetooth/fixtures";
|
||||
import { infraredFixtures } from "./infrared/fixtures";
|
||||
import { matterFixtures } from "./matter/fixtures";
|
||||
import { mqttFixtures } from "./mqtt/fixtures";
|
||||
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
|
||||
import { serialFixtures } from "./serial/fixtures";
|
||||
import { tagsFixtures } from "./tags/fixtures";
|
||||
import { threadFixtures } from "./thread/fixtures";
|
||||
import type { ConnectivityFixtures } from "./types";
|
||||
import { zhaFixtures } from "./zha/fixtures";
|
||||
import { zwaveJsFixtures } from "./zwave_js/fixtures";
|
||||
|
||||
// Every integration reachable from Settings > Connectivity that has frontend
|
||||
// data to mock. Each owns its own fixtures, so they can be added and removed
|
||||
// one at a time.
|
||||
const INTEGRATIONS: ConnectivityFixtures[] = [
|
||||
bluetoothFixtures,
|
||||
serialFixtures,
|
||||
mqttFixtures,
|
||||
matterFixtures,
|
||||
infraredFixtures,
|
||||
zwaveJsFixtures,
|
||||
zhaFixtures,
|
||||
radioFrequencyFixtures,
|
||||
tagsFixtures,
|
||||
threadFixtures,
|
||||
];
|
||||
|
||||
const collect = <T>(
|
||||
pick: (fixtures: ConnectivityFixtures) => T[] | undefined
|
||||
) => INTEGRATIONS.flatMap((fixtures) => pick(fixtures) ?? []);
|
||||
|
||||
export const connectivityComponents = collect((f) => f.components);
|
||||
|
||||
export const connectivityCommands = collect((f) => f.commands);
|
||||
|
||||
export const connectivityConfigEntries = collect((f) => f.configEntries);
|
||||
|
||||
export const connectivityManifests = collect((f) => f.manifests);
|
||||
|
||||
export const connectivityDevices = collect((f) => f.devices);
|
||||
|
||||
export const connectivityEntityRegistryEntries = collect(
|
||||
(f) => f.entityRegistryEntries
|
||||
);
|
||||
|
||||
export const connectivityEntities = () =>
|
||||
INTEGRATIONS.flatMap((fixtures) => fixtures.entities?.() ?? []);
|
||||
|
||||
/** Backend translation resources, merged per category. */
|
||||
export const connectivityBackendTranslations = INTEGRATIONS.reduce<
|
||||
Record<string, Record<string, string>>
|
||||
>((resources, fixtures) => {
|
||||
for (const [category, keys] of Object.entries(
|
||||
fixtures.backendTranslations ?? {}
|
||||
)) {
|
||||
resources[category] = { ...resources[category], ...keys };
|
||||
}
|
||||
return resources;
|
||||
}, {});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ConfigEntry } from "../../../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type { EntityInput } from "../../../../src/fake_data/entities/types";
|
||||
|
||||
// Builders for the registry fixtures, so each integration only spells out what
|
||||
// makes its own entries different.
|
||||
|
||||
const BASE_CONFIG_ENTRY = {
|
||||
source: "user",
|
||||
state: "loaded" as const,
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
|
||||
export const configEntry = (
|
||||
entryId: string,
|
||||
domain: string,
|
||||
title: string,
|
||||
extra: Partial<ConfigEntry> = {}
|
||||
): ConfigEntry => ({
|
||||
...BASE_CONFIG_ENTRY,
|
||||
entry_id: entryId,
|
||||
domain,
|
||||
title,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const BASE_DEVICE = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
entry_type: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const device = (
|
||||
id: string,
|
||||
name: string,
|
||||
manufacturer: string,
|
||||
model: string,
|
||||
entryId: string,
|
||||
extra: Partial<DeviceRegistryEntry> = {}
|
||||
): DeviceRegistryEntry => ({
|
||||
...BASE_DEVICE,
|
||||
id,
|
||||
name,
|
||||
manufacturer,
|
||||
model,
|
||||
config_entries: [entryId],
|
||||
primary_config_entry: entryId,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const BASE_REGISTRY_ENTRY = {
|
||||
config_subentry_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
icon: null,
|
||||
labels: [] as string[],
|
||||
categories: {},
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
options: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const registryEntry = (
|
||||
entityId: string,
|
||||
deviceId: string,
|
||||
entryId: string,
|
||||
platform: string,
|
||||
name?: string
|
||||
): EntityRegistryEntry => ({
|
||||
...BASE_REGISTRY_ENTRY,
|
||||
entity_id: entityId,
|
||||
id: entityId,
|
||||
unique_id: entityId,
|
||||
device_id: deviceId,
|
||||
config_entry_id: entryId,
|
||||
platform,
|
||||
name: name ?? null,
|
||||
has_entity_name: name === undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* The demo's `addEntities` builds the display entity registry from the entity
|
||||
* inputs, so mirror each state's registry entry onto it. Panels count entities
|
||||
* per device and per integration.
|
||||
*/
|
||||
export const withRegistryLinks = (
|
||||
entries: EntityRegistryEntry[],
|
||||
states: Record<string, EntityInput>
|
||||
): EntityInput[] =>
|
||||
Object.values(states).map((state) => {
|
||||
const entry = entries.find(
|
||||
(candidate) => candidate.entity_id === state.entity_id
|
||||
);
|
||||
return entry
|
||||
? { ...state, device_id: entry.device_id!, platform: entry.platform }
|
||||
: state;
|
||||
});
|
||||
|
||||
export const minutesAgo = (minutes: number) =>
|
||||
new Date(Date.now() - minutes * 60000).toISOString();
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { mockBluetooth } from "./bluetooth/mock";
|
||||
import { mockMatter } from "./matter/mock";
|
||||
import { mockMqtt } from "./mqtt/mock";
|
||||
import { mockRadioFrequency } from "./radio_frequency/mock";
|
||||
import { mockSerial } from "./serial/mock";
|
||||
import { mockThread } from "./thread/mock";
|
||||
import { mockZha } from "./zha/mock";
|
||||
import { mockZwaveJs } from "./zwave_js/mock";
|
||||
|
||||
// The WebSocket mocks, code-split into the config panel chunk.
|
||||
const MOCKS = [
|
||||
mockBluetooth,
|
||||
mockSerial,
|
||||
mockMatter,
|
||||
mockMqtt,
|
||||
mockZwaveJs,
|
||||
mockZha,
|
||||
mockRadioFrequency,
|
||||
mockThread,
|
||||
];
|
||||
|
||||
export const mockConnectivity = (hass: MockHomeAssistant) => {
|
||||
MOCKS.forEach((mock) => mock(hass));
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
minutesAgo,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-broadlink";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"broadlink-living-room",
|
||||
"Living room blaster",
|
||||
"Broadlink",
|
||||
"RM4 pro",
|
||||
ENTRY_ID,
|
||||
{ area_id: "living_room" }
|
||||
),
|
||||
device(
|
||||
"broadlink-bedroom",
|
||||
"Bedroom blaster",
|
||||
"Broadlink",
|
||||
"RM mini 3",
|
||||
ENTRY_ID,
|
||||
{ area_id: "bedroom" }
|
||||
),
|
||||
];
|
||||
|
||||
// The infrared panel is entity driven: the proxy entities live in the
|
||||
// `infrared` domain while their registry platform stays the integration that
|
||||
// provides them.
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"infrared.living_room_blaster_emitter",
|
||||
"broadlink-living-room",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Emitter"
|
||||
),
|
||||
registryEntry(
|
||||
"infrared.living_room_blaster_receiver",
|
||||
"broadlink-living-room",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Receiver"
|
||||
),
|
||||
registryEntry(
|
||||
"infrared.bedroom_blaster_emitter",
|
||||
"broadlink-bedroom",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Emitter"
|
||||
),
|
||||
];
|
||||
|
||||
export const infraredFixtures: ConnectivityFixtures = {
|
||||
components: ["infrared"],
|
||||
manifests: [
|
||||
manifest("broadlink", "Broadlink", {
|
||||
integration_type: "hub",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{ type: "hub", entry: configEntry(ENTRY_ID, "broadlink", "RM4 pro") },
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
// A proxy entity's state is the timestamp it was last used.
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"infrared.living_room_blaster_emitter": {
|
||||
entity_id: "infrared.living_room_blaster_emitter",
|
||||
state: minutesAgo(12),
|
||||
attributes: {
|
||||
friendly_name: "Living room blaster Emitter",
|
||||
device_class: "emitter",
|
||||
},
|
||||
},
|
||||
"infrared.living_room_blaster_receiver": {
|
||||
entity_id: "infrared.living_room_blaster_receiver",
|
||||
state: minutesAgo(3),
|
||||
attributes: {
|
||||
friendly_name: "Living room blaster Receiver",
|
||||
device_class: "receiver",
|
||||
},
|
||||
},
|
||||
"infrared.bedroom_blaster_emitter": {
|
||||
entity_id: "infrared.bedroom_blaster_emitter",
|
||||
state: minutesAgo(1440),
|
||||
attributes: {
|
||||
friendly_name: "Bedroom blaster Emitter",
|
||||
device_class: "emitter",
|
||||
},
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
entity_component: {
|
||||
// The emitter is the default device class, stored under the "_" key.
|
||||
"component.infrared.entity_component._.name": "Emitter",
|
||||
"component.infrared.entity_component.receiver.name": "Receiver",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-matter";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"matter-kitchen-light",
|
||||
"Kitchen ceiling",
|
||||
"Nanoleaf",
|
||||
"Essentials A19",
|
||||
ENTRY_ID,
|
||||
{ area_id: "kitchen", sw_version: "3.5.7" }
|
||||
),
|
||||
device(
|
||||
"matter-side-door-lock",
|
||||
"Side door lock",
|
||||
"Aqara",
|
||||
"Smart Lock U100",
|
||||
ENTRY_ID,
|
||||
{ sw_version: "1.2.0" }
|
||||
),
|
||||
device("matter-office-plug", "Office plug", "Eve", "Energy", ENTRY_ID, {
|
||||
area_id: "office",
|
||||
sw_version: "3.2.0",
|
||||
}),
|
||||
device("matter-patio-sensor", "Patio sensor", "Eve", "Weather", ENTRY_ID, {
|
||||
area_id: "garden",
|
||||
sw_version: "3.2.1",
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"light.kitchen_ceiling",
|
||||
"matter-kitchen-light",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
registryEntry("lock.side_door", "matter-side-door-lock", ENTRY_ID, "matter"),
|
||||
registryEntry("switch.office_plug", "matter-office-plug", ENTRY_ID, "matter"),
|
||||
registryEntry(
|
||||
"sensor.office_plug_power",
|
||||
"matter-office-plug",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.patio_temperature",
|
||||
"matter-patio-sensor",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
];
|
||||
|
||||
export const matterFixtures: ConnectivityFixtures = {
|
||||
components: ["matter"],
|
||||
commands: ["matter/"],
|
||||
manifests: [manifest("matter", "Matter", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "matter", "Matter", {
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.kitchen_ceiling": {
|
||||
entity_id: "light.kitchen_ceiling",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen ceiling",
|
||||
supported_color_modes: ["color_temp"],
|
||||
color_mode: "color_temp",
|
||||
brightness: 204,
|
||||
},
|
||||
},
|
||||
"lock.side_door": {
|
||||
entity_id: "lock.side_door",
|
||||
state: "locked",
|
||||
attributes: { friendly_name: "Side door lock" },
|
||||
},
|
||||
"switch.office_plug": {
|
||||
entity_id: "switch.office_plug",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Office plug" },
|
||||
},
|
||||
"sensor.office_plug_power": {
|
||||
entity_id: "sensor.office_plug_power",
|
||||
state: "42.5",
|
||||
attributes: {
|
||||
friendly_name: "Office plug power",
|
||||
device_class: "power",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "W",
|
||||
},
|
||||
},
|
||||
"sensor.patio_temperature": {
|
||||
entity_id: "sensor.patio_temperature",
|
||||
state: "14.2",
|
||||
attributes: {
|
||||
friendly_name: "Patio temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
import type {
|
||||
MatterCommissioningParameters,
|
||||
MatterFabricData,
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterNodeDiagnostics,
|
||||
} from "../../../../../src/data/matter";
|
||||
import { NetworkType, NodeType } from "../../../../../src/data/matter";
|
||||
import type {
|
||||
MatterLockInfo,
|
||||
MatterLockUser,
|
||||
MatterLockUsersResponse,
|
||||
SetMatterLockCredentialResult,
|
||||
} from "../../../../../src/data/matter-lock";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const EXT_PAN_ID = "dead00beef00cafe";
|
||||
const THREAD_NETWORK = "ha-thread";
|
||||
|
||||
const NODES: MatterNetworkTopologyNode[] = [
|
||||
{
|
||||
id: "otbr",
|
||||
kind: "border_router",
|
||||
network_type: "thread",
|
||||
ha_device_id: null,
|
||||
role: "leader",
|
||||
available: true,
|
||||
ext_address: "f6a1c30d2b4e5f61",
|
||||
rloc16: 0x4000,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
host_name: "homeassistant",
|
||||
vendor_name: "Home Assistant",
|
||||
model_name: "OpenThread Border Router",
|
||||
},
|
||||
{
|
||||
id: "wifi-ap",
|
||||
kind: "wifi_ap",
|
||||
network_type: "wifi",
|
||||
ha_device_id: null,
|
||||
available: true,
|
||||
ssid: "Home",
|
||||
bssid: "3c:37:86:11:22:33",
|
||||
vendor_name: "Ubiquiti",
|
||||
model_name: "U6 Pro",
|
||||
},
|
||||
{
|
||||
id: "node-1",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 1,
|
||||
ha_device_id: "matter-kitchen-light",
|
||||
available: true,
|
||||
role: "router",
|
||||
ext_address: "10a2b3c4d5e6f708",
|
||||
rloc16: 0x8401,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Nanoleaf",
|
||||
model_name: "Essentials A19",
|
||||
},
|
||||
{
|
||||
id: "node-2",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 2,
|
||||
ha_device_id: "matter-side-door-lock",
|
||||
available: true,
|
||||
role: "sleepy_end_device",
|
||||
ext_address: "20b3c4d5e6f70819",
|
||||
rloc16: 0x8402,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Aqara",
|
||||
model_name: "Smart Lock U100",
|
||||
},
|
||||
{
|
||||
id: "node-3",
|
||||
kind: "matter",
|
||||
network_type: "wifi",
|
||||
node_id: 3,
|
||||
ha_device_id: "matter-office-plug",
|
||||
available: true,
|
||||
ssid: "Home",
|
||||
vendor_name: "Eve",
|
||||
model_name: "Energy",
|
||||
},
|
||||
{
|
||||
id: "node-4",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 4,
|
||||
ha_device_id: "matter-patio-sensor",
|
||||
available: false,
|
||||
role: "end_device",
|
||||
ext_address: "30c4d5e6f708192a",
|
||||
rloc16: 0x8403,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Eve",
|
||||
model_name: "Weather",
|
||||
},
|
||||
{
|
||||
id: "thread-unknown-1",
|
||||
kind: "thread_unknown",
|
||||
network_type: "thread",
|
||||
available: true,
|
||||
role: "end_device",
|
||||
ext_address: "40d5e6f708192a3b",
|
||||
rloc16: 0x8404,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
},
|
||||
];
|
||||
|
||||
const connection = (
|
||||
source: string,
|
||||
target: string,
|
||||
network: string,
|
||||
strength: MatterNetworkTopologyConnection["strength"],
|
||||
lqi?: number,
|
||||
rssi?: number
|
||||
): MatterNetworkTopologyConnection => ({
|
||||
source,
|
||||
target,
|
||||
network,
|
||||
strength,
|
||||
source_to_target: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
|
||||
target_to_source: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
|
||||
via_route_table: false,
|
||||
path_cost: null,
|
||||
});
|
||||
|
||||
const CONNECTIONS: MatterNetworkTopologyConnection[] = [
|
||||
connection("otbr", "node-1", "thread", "strong", 245, -42),
|
||||
connection("otbr", "node-2", "thread", "medium", 160, -68),
|
||||
connection("node-1", "node-4", "thread", "weak", 84, -86),
|
||||
connection("node-1", "thread-unknown-1", "thread", "medium", 172, -63),
|
||||
connection("wifi-ap", "node-3", "wifi", "strong", undefined, -47),
|
||||
];
|
||||
|
||||
const TOPOLOGY: MatterNetworkTopology = {
|
||||
collected_at: Date.now() / 1000,
|
||||
nodes: NODES,
|
||||
connections: CONNECTIONS,
|
||||
};
|
||||
|
||||
const buildTopology = (): MatterNetworkTopology => ({
|
||||
...TOPOLOGY,
|
||||
collected_at: Date.now() / 1000,
|
||||
});
|
||||
|
||||
const FABRICS: MatterFabricData[] = [
|
||||
{
|
||||
fabric_id: 1,
|
||||
vendor_id: 4939,
|
||||
fabric_index: 1,
|
||||
fabric_label: "Home Assistant",
|
||||
vendor_name: "Home Assistant",
|
||||
},
|
||||
];
|
||||
|
||||
const NODE_TYPE_BY_ROLE: Record<string, NodeType> = {
|
||||
router: NodeType.ROUTING_END_DEVICE,
|
||||
sleepy_end_device: NodeType.SLEEPY_END_DEVICE,
|
||||
end_device: NodeType.END_DEVICE,
|
||||
};
|
||||
|
||||
const NODES_BY_DEVICE_ID = new Map(
|
||||
NODES.filter((node) => node.ha_device_id).map((node) => [
|
||||
node.ha_device_id!,
|
||||
node,
|
||||
])
|
||||
);
|
||||
|
||||
const nodeIpAddress = (node: MatterNetworkTopologyNode): string =>
|
||||
node.network_type === "thread"
|
||||
? `fd11:2233:4455:6677::${(node.node_id ?? 0).toString(16)}`
|
||||
: `192.168.1.${100 + (node.node_id ?? 0)}`;
|
||||
|
||||
// Diagnostics are derived from the topology so a device's transport,
|
||||
// availability and node type match the map. The device page reads them per
|
||||
// device, and gates its actions on `available` and `network_type`.
|
||||
const buildNodeDiagnostics = (
|
||||
node: MatterNetworkTopologyNode
|
||||
): MatterNodeDiagnostics => ({
|
||||
node_id: node.node_id!,
|
||||
network_type:
|
||||
node.network_type === "thread" ? NetworkType.THREAD : NetworkType.WIFI,
|
||||
node_type: node.is_bridge
|
||||
? NodeType.BRIDGE
|
||||
: (NODE_TYPE_BY_ROLE[node.role ?? ""] ?? NodeType.END_DEVICE),
|
||||
network_name: node.network_name ?? node.ssid ?? undefined,
|
||||
ip_adresses: [nodeIpAddress(node)],
|
||||
mac_address: node.ext_address?.match(/.{2}/g)?.join(":"),
|
||||
available: node.available !== false,
|
||||
active_fabrics: FABRICS,
|
||||
active_fabric_index: 1,
|
||||
});
|
||||
|
||||
// The backend resolves the device before acting, so both node commands answer
|
||||
// the same way when it cannot.
|
||||
const nodeNotFound = (deviceId: string) =>
|
||||
Promise.reject({
|
||||
code: "node_not_found",
|
||||
message: `No Matter node for device ${deviceId}`,
|
||||
});
|
||||
|
||||
// The manual and QR codes below are the Matter test payload for passcode
|
||||
// 20202021 with discriminator 3840; the three have to agree.
|
||||
const COMMISSIONING_PARAMETERS: MatterCommissioningParameters = {
|
||||
setup_pin_code: 20202021,
|
||||
setup_manual_code: "34970112332",
|
||||
setup_qr_code: "MT:Y.K9042C00KA0648G00",
|
||||
};
|
||||
|
||||
const LOCK_INFO: MatterLockInfo = {
|
||||
supports_user_management: true,
|
||||
supported_credential_types: ["pin"],
|
||||
max_users: 10,
|
||||
max_pin_users: 10,
|
||||
max_rfid_users: null,
|
||||
max_credentials_per_user: 2,
|
||||
min_pin_length: 4,
|
||||
max_pin_length: 8,
|
||||
min_rfid_length: null,
|
||||
max_rfid_length: null,
|
||||
};
|
||||
|
||||
const initialLockUsers = (): MatterLockUser[] => [
|
||||
{
|
||||
user_index: 1,
|
||||
user_name: "Anne",
|
||||
user_unique_id: 1,
|
||||
user_status: "occupied_enabled",
|
||||
user_type: "unrestricted_user",
|
||||
credential_rule: "single",
|
||||
credentials: [{ type: "pin", index: 1 }],
|
||||
next_user_index: 2,
|
||||
},
|
||||
{
|
||||
user_index: 2,
|
||||
user_name: "Cleaner",
|
||||
user_unique_id: 2,
|
||||
user_status: "occupied_disabled",
|
||||
user_type: "week_day_schedule_user",
|
||||
credential_rule: "single",
|
||||
credentials: [{ type: "pin", index: 2 }],
|
||||
next_user_index: null,
|
||||
},
|
||||
];
|
||||
|
||||
// The manage dialog reloads the list after every add, edit and delete, so the
|
||||
// mocked services keep the lock's users rather than answering from a constant,
|
||||
// which would make every change look like it was reverted.
|
||||
const lockUsers = new Map<string, MatterLockUser[]>();
|
||||
|
||||
const usersFor = (entityId: string): MatterLockUser[] => {
|
||||
let users = lockUsers.get(entityId);
|
||||
if (!users) {
|
||||
users = initialLockUsers();
|
||||
lockUsers.set(entityId, users);
|
||||
}
|
||||
return users;
|
||||
};
|
||||
|
||||
// Lowest free slot, the way a lock hands out user and credential indexes.
|
||||
const nextFreeIndex = (taken: number[]): number => {
|
||||
let index = 1;
|
||||
while (taken.includes(index)) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
export const mockMatter = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("matter/network_topology", () => buildTopology());
|
||||
|
||||
hass.mockWS("matter/subscribe_network_topology", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.(buildTopology()))
|
||||
);
|
||||
|
||||
hass.mockWS("matter/node_diagnostics", (msg: { device_id: string }) => {
|
||||
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
|
||||
return node ? buildNodeDiagnostics(node) : nodeNotFound(msg.device_id);
|
||||
});
|
||||
|
||||
hass.mockWS("matter/ping_node", (msg: { device_id: string }) => {
|
||||
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
|
||||
return node
|
||||
? { [nodeIpAddress(node)]: node.available !== false }
|
||||
: nodeNotFound(msg.device_id);
|
||||
});
|
||||
|
||||
hass.mockWS("matter/interview_node", () => undefined);
|
||||
|
||||
// Actions the device page offers for an available node. Without these the
|
||||
// dialogs behind them fail with `command_not_mocked`.
|
||||
hass.mockWS(
|
||||
"matter/open_commissioning_window",
|
||||
() => COMMISSIONING_PARAMETERS
|
||||
);
|
||||
hass.mockWS("matter/remove_matter_fabric", () => undefined);
|
||||
hass.mockWS("matter/set_wifi_credentials", () => undefined);
|
||||
hass.mockWS("matter/set_thread", () => undefined);
|
||||
|
||||
// The lock device exposes "Manage lock", whose dialog reads back the response
|
||||
// of these services.
|
||||
hass.mockService("matter", "get_lock_info", (_data, target) => ({
|
||||
[target!.entity_id]: LOCK_INFO,
|
||||
}));
|
||||
hass.mockService("matter", "get_lock_users", (_data, target) => ({
|
||||
// Copied, the way a real response would be: the dialog assigns the list to
|
||||
// reactive state, so handing back the same array leaves it unchanged and
|
||||
// the list never rerenders.
|
||||
[target!.entity_id]: {
|
||||
max_users: LOCK_INFO.max_users!,
|
||||
users: usersFor(target!.entity_id).map((user) => ({
|
||||
...user,
|
||||
credentials: user.credentials.map((credential) => ({ ...credential })),
|
||||
})),
|
||||
} satisfies MatterLockUsersResponse,
|
||||
}));
|
||||
|
||||
// Renames the user the credential below created, or edits an existing one.
|
||||
hass.mockService("matter", "set_lock_user", (data, target) => {
|
||||
const user = usersFor(target!.entity_id).find(
|
||||
(candidate) => candidate.user_index === data?.user_index
|
||||
);
|
||||
if (user) {
|
||||
if (data?.user_name !== undefined) {
|
||||
user.user_name = data.user_name;
|
||||
}
|
||||
if (data?.user_type !== undefined) {
|
||||
user.user_type = data.user_type;
|
||||
}
|
||||
if (data?.credential_rule !== undefined) {
|
||||
user.credential_rule = data.credential_rule;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
hass.mockService("matter", "clear_lock_user", (data, target) => {
|
||||
const users = usersFor(target!.entity_id);
|
||||
const index = users.findIndex(
|
||||
(candidate) => candidate.user_index === data?.user_index
|
||||
);
|
||||
if (index !== -1) {
|
||||
users.splice(index, 1);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// Adding a user starts here: the credential creates it, and the dialog reads
|
||||
// the assigned index straight back to name it.
|
||||
hass.mockService("matter", "set_lock_credential", (data, target) => {
|
||||
const users = usersFor(target!.entity_id);
|
||||
const userIndex =
|
||||
(data?.user_index as number | null | undefined) ??
|
||||
nextFreeIndex(
|
||||
users
|
||||
.map((user) => user.user_index)
|
||||
.filter((i): i is number => i !== null)
|
||||
);
|
||||
const credentialIndex =
|
||||
(data?.credential_index as number | null | undefined) ??
|
||||
nextFreeIndex(
|
||||
users.flatMap((user) =>
|
||||
user.credentials
|
||||
.map((credential) => credential.index)
|
||||
.filter((i): i is number => i !== null)
|
||||
)
|
||||
);
|
||||
const credential = {
|
||||
type: (data?.credential_type as string) ?? "pin",
|
||||
index: credentialIndex,
|
||||
};
|
||||
const user = users.find((candidate) => candidate.user_index === userIndex);
|
||||
if (user) {
|
||||
user.credentials = [...user.credentials, credential];
|
||||
} else {
|
||||
users.push({
|
||||
user_index: userIndex,
|
||||
user_name: null,
|
||||
user_unique_id: userIndex,
|
||||
user_status: data?.user_status ?? "occupied_enabled",
|
||||
user_type: data?.user_type ?? "unrestricted_user",
|
||||
credential_rule: "single",
|
||||
credentials: [credential],
|
||||
next_user_index: null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
[target!.entity_id]: {
|
||||
credential_index: credentialIndex,
|
||||
user_index: userIndex,
|
||||
next_credential_index: null,
|
||||
} satisfies SetMatterLockCredentialResult,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-mqtt";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"mqtt-fridge-sensor",
|
||||
"Fridge sensor",
|
||||
"Xiaomi",
|
||||
"LYWSD03MMC",
|
||||
ENTRY_ID,
|
||||
{ area_id: "kitchen" }
|
||||
),
|
||||
device(
|
||||
"mqtt-garage-door",
|
||||
"Garage door",
|
||||
"Shelly",
|
||||
"Shelly Plus 1",
|
||||
ENTRY_ID
|
||||
),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"sensor.fridge_temperature",
|
||||
"mqtt-fridge-sensor",
|
||||
ENTRY_ID,
|
||||
"mqtt"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.fridge_battery",
|
||||
"mqtt-fridge-sensor",
|
||||
ENTRY_ID,
|
||||
"mqtt"
|
||||
),
|
||||
registryEntry("cover.garage_door", "mqtt-garage-door", ENTRY_ID, "mqtt"),
|
||||
];
|
||||
|
||||
export const mqttFixtures: ConnectivityFixtures = {
|
||||
components: ["mqtt"],
|
||||
// `execute_script` too: the panel publishes through a script action.
|
||||
commands: ["mqtt/", "execute_script"],
|
||||
manifests: [manifest("mqtt", "MQTT", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "mqtt", "core-mosquitto", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"sensor.fridge_temperature": {
|
||||
entity_id: "sensor.fridge_temperature",
|
||||
state: "21.4",
|
||||
attributes: {
|
||||
friendly_name: "Fridge temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"sensor.fridge_battery": {
|
||||
entity_id: "sensor.fridge_battery",
|
||||
state: "92",
|
||||
attributes: {
|
||||
friendly_name: "Fridge battery",
|
||||
device_class: "battery",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"cover.garage_door": {
|
||||
entity_id: "cover.garage_door",
|
||||
state: "closed",
|
||||
attributes: { friendly_name: "Garage door", device_class: "garage" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
MQTTDeviceDebugInfo,
|
||||
MQTTMessage,
|
||||
} from "../../../../../src/data/mqtt";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const PAYLOADS: Record<string, () => string> = {
|
||||
"homeassistant/status": () => "online",
|
||||
"zigbee2mqtt/bridge/state": () => '{"state":"online"}',
|
||||
default: () =>
|
||||
JSON.stringify({
|
||||
battery: 92,
|
||||
linkquality: 120,
|
||||
// Built from whole tenths, so the payload never carries the noise a
|
||||
// float sum leaves behind, like 21.599999999999998.
|
||||
temperature: (214 + Math.floor(Math.random() * 11)) / 10,
|
||||
}),
|
||||
};
|
||||
|
||||
// Subscriptions take a topic filter, but a message carries the topic it was
|
||||
// actually published on, so a filter has to be resolved to one concrete topic
|
||||
// before it can be echoed back.
|
||||
const resolveFilter = (filter: string): string =>
|
||||
filter
|
||||
.split("/")
|
||||
.flatMap((level) => {
|
||||
if (level === "+") {
|
||||
return ["kitchen"];
|
||||
}
|
||||
if (level === "#") {
|
||||
return ["kitchen", "temperature"];
|
||||
}
|
||||
return [level];
|
||||
})
|
||||
.join("/") || "homeassistant/status";
|
||||
|
||||
const buildMessage = (topic: string, qos: number): MQTTMessage => ({
|
||||
topic,
|
||||
payload: (PAYLOADS[topic] ?? PAYLOADS.default)(),
|
||||
qos,
|
||||
retain: 0,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// A filter matches a topic level by level: "+" stands for one level, "#" for
|
||||
// the rest of them.
|
||||
const filterMatches = (filter: string, topic: string): boolean => {
|
||||
const filterLevels = filter.split("/");
|
||||
const topicLevels = topic.split("/");
|
||||
for (let index = 0; index < filterLevels.length; index += 1) {
|
||||
if (filterLevels[index] === "#") {
|
||||
return true;
|
||||
}
|
||||
if (index >= topicLevels.length) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filterLevels[index] !== "+" &&
|
||||
filterLevels[index] !== topicLevels[index]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return filterLevels.length === topicLevels.length;
|
||||
};
|
||||
|
||||
// The panel's listen card and its publish button talk to each other through
|
||||
// the broker, so the mock keeps the subscriptions and delivers to them.
|
||||
const subscriptions = new Set<{
|
||||
filter: string;
|
||||
qos: number;
|
||||
deliver: (message: MQTTMessage) => void;
|
||||
}>();
|
||||
|
||||
const topicDebug = (topic: string) => ({
|
||||
topic,
|
||||
messages: [buildMessage(topic, 0)],
|
||||
});
|
||||
|
||||
// Keyed by device, the way the backend builds this per requested device.
|
||||
const DEBUG_INFO: Record<string, MQTTDeviceDebugInfo> = {
|
||||
"mqtt-fridge-sensor": {
|
||||
entities: [
|
||||
{
|
||||
entity_id: "sensor.fridge_temperature",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/sensor/fridge/temperature/config",
|
||||
payload: {
|
||||
name: "Temperature",
|
||||
state_topic: "zigbee2mqtt/fridge",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
|
||||
transmitted: [],
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.fridge_battery",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/sensor/fridge/battery/config",
|
||||
payload: {
|
||||
name: "Battery",
|
||||
state_topic: "zigbee2mqtt/fridge",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
|
||||
transmitted: [],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
"mqtt-garage-door": {
|
||||
entities: [
|
||||
{
|
||||
entity_id: "cover.garage_door",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/cover/garage/config",
|
||||
payload: {
|
||||
name: "Garage door",
|
||||
state_topic: "shellyplus1/status/cover:0",
|
||||
command_topic: "shellyplus1/command/cover:0",
|
||||
device_class: "garage",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("shellyplus1/status/cover:0")],
|
||||
transmitted: [topicDebug("shellyplus1/command/cover:0")],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const mockMqtt = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"mqtt/subscribe",
|
||||
(msg: { topic: string; qos?: number }, _hass, onChange) => {
|
||||
// Echo a message on the subscribed topic every few seconds so the
|
||||
// listen card in the MQTT panel shows traffic.
|
||||
const qos = msg.qos ?? 0;
|
||||
const topic = resolveFilter(msg.topic);
|
||||
const deliver = (message: MQTTMessage) => onChange?.(message);
|
||||
const subscription = { filter: msg.topic, qos, deliver };
|
||||
subscriptions.add(subscription);
|
||||
const send = () => deliver(buildMessage(topic, qos));
|
||||
const stopInitial = emitInitial(send);
|
||||
const interval = window.setInterval(send, 3000);
|
||||
return () => {
|
||||
stopInitial();
|
||||
clearInterval(interval);
|
||||
subscriptions.delete(subscription);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// The panel publishes through a script action rather than a `mqtt/` command,
|
||||
// so without this the publish button only ever reports a failure. Delivering
|
||||
// to the matching subscriptions is what makes the two halves of the panel
|
||||
// work together.
|
||||
hass.mockWS(
|
||||
"execute_script",
|
||||
(msg: { sequence: { action?: string; data?: Record<string, any> }[] }) => {
|
||||
msg.sequence
|
||||
?.filter((action) => action.action === "mqtt.publish")
|
||||
.forEach((action) => {
|
||||
const topic = String(action.data?.topic ?? "");
|
||||
const message: MQTTMessage = {
|
||||
topic,
|
||||
payload: String(action.data?.payload ?? ""),
|
||||
qos: Number(action.data?.qos ?? 0),
|
||||
retain: action.data?.retain ? 1 : 0,
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
subscriptions.forEach((subscription) => {
|
||||
if (filterMatches(subscription.filter, topic)) {
|
||||
subscription.deliver(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { context: { id: "mock-context" }, response: {} };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"mqtt/device/debug_info",
|
||||
(msg: { device_id: string }): MQTTDeviceDebugInfo =>
|
||||
DEBUG_INFO[msg.device_id] ?? { entities: [], triggers: [] }
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
minutesAgo,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-rf-bridge";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"rf-bridge-garage",
|
||||
"Garage bridge",
|
||||
"Sonoff",
|
||||
"RF Bridge R2",
|
||||
ENTRY_ID,
|
||||
{ area_id: "garden" }
|
||||
),
|
||||
device("rf-bridge-shed", "Shed bridge", "Sonoff", "RF Bridge R2", ENTRY_ID),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"radio_frequency.garage_bridge",
|
||||
"rf-bridge-garage",
|
||||
ENTRY_ID,
|
||||
"esphome",
|
||||
"Transceiver"
|
||||
),
|
||||
registryEntry(
|
||||
"radio_frequency.shed_bridge",
|
||||
"rf-bridge-shed",
|
||||
ENTRY_ID,
|
||||
"esphome",
|
||||
"Transceiver"
|
||||
),
|
||||
];
|
||||
|
||||
export const radioFrequencyFixtures: ConnectivityFixtures = {
|
||||
components: ["radio_frequency"],
|
||||
commands: ["radio_frequency/"],
|
||||
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
|
||||
configEntries: [
|
||||
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"radio_frequency.garage_bridge": {
|
||||
entity_id: "radio_frequency.garage_bridge",
|
||||
state: minutesAgo(47),
|
||||
attributes: { friendly_name: "Garage bridge Transceiver" },
|
||||
},
|
||||
"radio_frequency.shed_bridge": {
|
||||
entity_id: "radio_frequency.shed_bridge",
|
||||
state: "unknown",
|
||||
attributes: { friendly_name: "Shed bridge Transceiver" },
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
entity_component: {
|
||||
"component.radio_frequency.entity_component._.name": "Transceiver",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RadioFrequencyTransmitter } from "../../../../../src/data/radio_frequency";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
const TRANSMITTERS: RadioFrequencyTransmitter[] = [
|
||||
{
|
||||
entity_id: "radio_frequency.garage_bridge",
|
||||
device_id: "rf-bridge-garage",
|
||||
config_entry_id: "mock-rf-bridge",
|
||||
supported_frequency_ranges: [[433920000, 433920000]],
|
||||
supported_modulations: ["OOK"],
|
||||
},
|
||||
{
|
||||
entity_id: "radio_frequency.shed_bridge",
|
||||
device_id: "rf-bridge-shed",
|
||||
config_entry_id: "mock-rf-bridge",
|
||||
supported_frequency_ranges: [[433920000, 433920000]],
|
||||
supported_modulations: ["OOK"],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockRadioFrequency = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("radio_frequency/list", () => ({
|
||||
transmitters: TRANSMITTERS,
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const serialFixtures: ConnectivityFixtures = {
|
||||
components: ["usb"],
|
||||
commands: ["usb/"],
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { SerialPortUsage } from "../../../../../src/data/usb";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
const PORTS: SerialPortUsage[] = [
|
||||
{
|
||||
device: "/dev/ttyUSB0",
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-Nabu_Casa_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
|
||||
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
|
||||
manufacturer: "Nabu Casa",
|
||||
description: "Home Assistant Connect ZBT-1",
|
||||
interface_description: "Connect ZBT-1",
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: 256,
|
||||
matching_integrations: ["zha"],
|
||||
present: true,
|
||||
consumers: [
|
||||
{
|
||||
kind: "config_entry",
|
||||
title: "Home Assistant Connect ZBT-1",
|
||||
active: true,
|
||||
domain: "zha",
|
||||
config_entry_id: "mock-zha",
|
||||
slug: null,
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
device: "/dev/ttyACM0",
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-Zooz_800_Z-Wave_Stick_533D004242-if00",
|
||||
serial_number: "533D004242",
|
||||
manufacturer: "Zooz",
|
||||
description: "800 Series Z-Wave Long Range",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: 256,
|
||||
matching_integrations: ["zwave_js"],
|
||||
present: true,
|
||||
consumers: [
|
||||
{
|
||||
kind: "config_entry",
|
||||
title: "Z-Wave",
|
||||
active: true,
|
||||
domain: "zwave_js",
|
||||
config_entry_id: "mock-zwave-js",
|
||||
slug: null,
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
device: "/dev/ttyUSB1",
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
|
||||
serial_number: "A50285BI",
|
||||
manufacturer: "FTDI",
|
||||
description: "FT232R USB UART",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "0403",
|
||||
pid: "6001",
|
||||
bcd_device: 1536,
|
||||
matching_integrations: [],
|
||||
present: true,
|
||||
consumers: [],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
// A port that is configured but not currently plugged in. Its add-on icon
|
||||
// is requested straight from /api/hassio/addons/<slug>/icon, which the
|
||||
// demo has no backend for, so the icon stays blank here.
|
||||
device: "/dev/ttyUSB2",
|
||||
resolved_device: null,
|
||||
serial_number: "0001",
|
||||
manufacturer: "Silicon Labs",
|
||||
description: "CP2102 USB to UART Bridge Controller",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: null,
|
||||
matching_integrations: [],
|
||||
present: false,
|
||||
consumers: [
|
||||
{
|
||||
kind: "app",
|
||||
title: "ESPHome Device Builder",
|
||||
active: false,
|
||||
domain: null,
|
||||
config_entry_id: null,
|
||||
slug: "esphome",
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSerial = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("usb/list_serial_ports", () => PORTS);
|
||||
hass.mockWS("usb/scan", () => undefined);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// Mocked WebSocket subscriptions are registered synchronously, so a callback
|
||||
// invoked straight away can land before the subscriber is ready for it: pages
|
||||
// that ignore messages received before their first render drop it, and
|
||||
// `createCollection` overwrites it with the empty initial fetch. Emitting the
|
||||
// first message from a timeout matches the real backend, which always answers
|
||||
// asynchronously.
|
||||
export const emitInitial = (send: () => void): (() => void) => {
|
||||
const timeout = window.setTimeout(send, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const tagsFixtures: ConnectivityFixtures = {
|
||||
components: ["tag"],
|
||||
commands: ["tag/"],
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import { configEntry } from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const THREAD_ENTRY_ID = "mock-thread";
|
||||
const OTBR_ENTRY_ID = "mock-otbr";
|
||||
|
||||
export const threadFixtures: ConnectivityFixtures = {
|
||||
components: ["thread", "otbr"],
|
||||
commands: ["thread/", "otbr/"],
|
||||
manifests: [
|
||||
manifest("thread", "Thread", {
|
||||
integration_type: "service",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("otbr", "Open Thread Border Router", {
|
||||
integration_type: "service",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "service",
|
||||
entry: configEntry(THREAD_ENTRY_ID, "thread", "Thread"),
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: configEntry(OTBR_ENTRY_ID, "otbr", "Open Thread Border Router"),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,441 @@
|
||||
import type { OTBRInfoDict } from "../../../../../src/data/otbr";
|
||||
import type {
|
||||
ThreadDataSet,
|
||||
ThreadRouter,
|
||||
} from "../../../../../src/data/thread";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const HA_EXT_PAN_ID = "DEAD00BEEF00CAFE";
|
||||
const AMAZON_EXT_PAN_ID = "0011223344556677";
|
||||
|
||||
const OTBR_EXT_ADDRESS = "f6a1c30d2b4e5f61";
|
||||
const OTBR_BORDER_AGENT_ID = "230c6a1ac57f6f4be262acf32e5ef52c";
|
||||
|
||||
const ROUTERS: ThreadRouter[] = [
|
||||
{
|
||||
instance_name: "HomeAssistant OpenThreadBorderRouter",
|
||||
addresses: ["192.168.1.10"],
|
||||
border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
brand: "homeassistant",
|
||||
extended_address: OTBR_EXT_ADDRESS,
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "OpenThread Border Router",
|
||||
network_name: "ha-thread",
|
||||
server: "core-openthread-border-router.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Home Assistant",
|
||||
},
|
||||
{
|
||||
instance_name: "HomePod mini",
|
||||
addresses: ["192.168.1.24"],
|
||||
border_agent_id: "6a1ac57f6f4be262acf32e5ef52c230c",
|
||||
brand: "apple",
|
||||
extended_address: "aabbccddeeff0011",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "HomePod mini",
|
||||
network_name: "ha-thread",
|
||||
server: "homepod-mini.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Apple Inc.",
|
||||
},
|
||||
{
|
||||
instance_name: "Nest Hub",
|
||||
addresses: ["192.168.1.31"],
|
||||
border_agent_id: "ac57f6f4be262acf32e5ef52c230c6a1",
|
||||
brand: "google",
|
||||
extended_address: "bbccddeeff001122",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "Google Nest Hub",
|
||||
network_name: "ha-thread",
|
||||
server: "nest-hub.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Google Inc.",
|
||||
},
|
||||
{
|
||||
instance_name: "Echo (4th Gen)",
|
||||
addresses: ["192.168.1.42"],
|
||||
border_agent_id: "57f6f4be262acf32e5ef52c230c6a1ac",
|
||||
brand: "amazon",
|
||||
extended_address: "ccddeeff00112233",
|
||||
extended_pan_id: AMAZON_EXT_PAN_ID,
|
||||
model_name: "Echo",
|
||||
network_name: "AmazonThread",
|
||||
server: "amazon-echo.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Amazon",
|
||||
},
|
||||
];
|
||||
|
||||
const decodeUtf8 = (value: string): string | undefined => {
|
||||
const bytes = Uint8Array.from(
|
||||
(value.match(/../g) ?? []).map((byte) => parseInt(byte, 16))
|
||||
);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const tlv = (type: number, value: string) =>
|
||||
type.toString(16).padStart(2, "0").toUpperCase() +
|
||||
(value.length / 2).toString(16).padStart(2, "0").toUpperCase() +
|
||||
value.toUpperCase();
|
||||
|
||||
const textToHex = (text: string) =>
|
||||
Array.from(text)
|
||||
.map((character) => character.charCodeAt(0).toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const replaceTlvField = (value: string, type: number, replacement: string) => {
|
||||
let index = 0;
|
||||
let out = "";
|
||||
let replaced = false;
|
||||
while (index + 4 <= value.length) {
|
||||
const fieldType = parseInt(value.slice(index, index + 2), 16);
|
||||
const length = parseInt(value.slice(index + 2, index + 4), 16);
|
||||
const end = index + 4 + length * 2;
|
||||
out +=
|
||||
fieldType === type ? tlv(type, replacement) : value.slice(index, end);
|
||||
replaced = replaced || fieldType === type;
|
||||
index = end;
|
||||
}
|
||||
return replaced ? out : out + tlv(type, replacement);
|
||||
};
|
||||
|
||||
const buildDatasetTlv = (dataset: ThreadDataSet) =>
|
||||
[
|
||||
tlv(0x0e, "0000000000010000"),
|
||||
tlv(0x00, `00${(dataset.channel ?? 15).toString(16).padStart(4, "0")}`),
|
||||
tlv(0x35, "000040010200"),
|
||||
tlv(0x02, dataset.extended_pan_id),
|
||||
tlv(0x07, "FD11220000000000"),
|
||||
tlv(0x05, "00112233445566778899AABBCCDDEEFF"),
|
||||
dataset.network_name === null
|
||||
? ""
|
||||
: tlv(0x03, textToHex(dataset.network_name)),
|
||||
tlv(0x01, (dataset.pan_id ?? "1234").padStart(4, "0")),
|
||||
tlv(0x04, "1035060004001FFFE00C0402A0F7F800"),
|
||||
tlv(0x0c, "02A0F7F8"),
|
||||
].join("");
|
||||
|
||||
const parseDatasetTlv = (value: string) => {
|
||||
if (value.length % 2 !== 0 || !/^[0-9A-Fa-f]*$/.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const fields = new Map<number, string>();
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
if (index + 4 > value.length) {
|
||||
return undefined;
|
||||
}
|
||||
const type = parseInt(value.slice(index, index + 2), 16);
|
||||
const length = parseInt(value.slice(index + 2, index + 4), 16);
|
||||
const start = index + 4;
|
||||
const end = start + length * 2;
|
||||
if (end > value.length) {
|
||||
return undefined;
|
||||
}
|
||||
if (fields.has(type)) {
|
||||
return undefined;
|
||||
}
|
||||
fields.set(type, value.slice(start, end).toUpperCase());
|
||||
index = end;
|
||||
}
|
||||
const extendedPanId = fields.get(0x02);
|
||||
const networkName = fields.get(0x03);
|
||||
const activeTimestamp = fields.get(0x0e);
|
||||
if (
|
||||
!extendedPanId ||
|
||||
extendedPanId.length !== 16 ||
|
||||
!activeTimestamp ||
|
||||
activeTimestamp.length !== 16
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const decodedName =
|
||||
networkName === undefined ? null : decodeUtf8(networkName);
|
||||
if (decodedName === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = fields.get(0x00);
|
||||
const channelNumber = channel ? parseInt(channel.slice(2), 16) : null;
|
||||
if (channelNumber === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
activeTimestamp,
|
||||
extendedPanId,
|
||||
networkName: decodedName,
|
||||
panId: fields.get(0x01) ?? null,
|
||||
channel: channelNumber,
|
||||
};
|
||||
};
|
||||
|
||||
const DATASETS: ThreadDataSet[] = [
|
||||
{
|
||||
channel: 15,
|
||||
created: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
dataset_id: "ha-thread-dataset",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
network_name: "ha-thread",
|
||||
pan_id: "1234",
|
||||
preferred_border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
preferred_extended_address: OTBR_EXT_ADDRESS,
|
||||
preferred: true,
|
||||
source: "otbr",
|
||||
},
|
||||
];
|
||||
|
||||
const DATASET_TLVS: Record<string, string> = Object.fromEntries(
|
||||
DATASETS.map((dataset) => [dataset.dataset_id, buildDatasetTlv(dataset)])
|
||||
);
|
||||
|
||||
const OTBR_INFO: OTBRInfoDict = {
|
||||
[OTBR_EXT_ADDRESS]: {
|
||||
active_dataset_tlvs: DATASET_TLVS["ha-thread-dataset"],
|
||||
border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
channel: 15,
|
||||
extended_address: OTBR_EXT_ADDRESS,
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
url: "http://core-openthread-border-router:8081",
|
||||
},
|
||||
};
|
||||
|
||||
let added = 0;
|
||||
let created = 0;
|
||||
|
||||
const randomExtendedPanId = () =>
|
||||
Array.from({ length: 8 }, () =>
|
||||
Math.floor(Math.random() * 256)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const moveRouter = (
|
||||
extendedAddress: string,
|
||||
extendedPanId: string,
|
||||
networkName: string | null,
|
||||
datasetId: string
|
||||
) => {
|
||||
const info = OTBR_INFO[extendedAddress];
|
||||
const moved = DATASETS.find((item) => item.dataset_id === datasetId);
|
||||
if (info) {
|
||||
info.extended_pan_id = extendedPanId;
|
||||
info.active_dataset_tlvs =
|
||||
DATASET_TLVS[datasetId] ?? info.active_dataset_tlvs;
|
||||
info.channel = moved?.channel ?? info.channel;
|
||||
}
|
||||
const router = ROUTERS.find(
|
||||
(candidate) => candidate.extended_address === extendedAddress
|
||||
);
|
||||
if (router) {
|
||||
router.extended_pan_id = extendedPanId;
|
||||
router.network_name = networkName;
|
||||
announce(router);
|
||||
}
|
||||
};
|
||||
|
||||
type RouterListener = (event: {
|
||||
key: string;
|
||||
type: "router_discovered" | "router_removed";
|
||||
data: ThreadRouter;
|
||||
}) => void;
|
||||
|
||||
const listeners = new Set<RouterListener>();
|
||||
|
||||
const announce = (router: ThreadRouter) =>
|
||||
listeners.forEach((listener) =>
|
||||
listener({
|
||||
key: router.extended_address,
|
||||
type: "router_discovered",
|
||||
data: router,
|
||||
})
|
||||
);
|
||||
|
||||
export const mockThread = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("thread/discover_routers", (_msg, _hass, onChange) => {
|
||||
const listener = onChange as RouterListener | undefined;
|
||||
if (listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
const stopInitial = emitInitial(() => ROUTERS.forEach(announce));
|
||||
return () => {
|
||||
stopInitial();
|
||||
if (listener) {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
hass.mockWS("thread/list_datasets", () => ({
|
||||
datasets: DATASETS.map((dataset) => ({ ...dataset })),
|
||||
}));
|
||||
hass.mockWS("thread/get_dataset_tlv", (msg: { dataset_id: string }) => {
|
||||
const value = DATASET_TLVS[msg.dataset_id];
|
||||
if (!value) {
|
||||
throw new Error(`Dataset ${msg.dataset_id} not found`);
|
||||
}
|
||||
return { tlv: value };
|
||||
});
|
||||
|
||||
hass.mockWS("otbr/info", () => OTBR_INFO);
|
||||
|
||||
hass.mockWS(
|
||||
"thread/add_dataset_tlv",
|
||||
(msg: { source: string; tlv: string }) => {
|
||||
const parsed = parseDatasetTlv(msg.tlv);
|
||||
if (!parsed) {
|
||||
throw new Error("Invalid dataset");
|
||||
}
|
||||
const existing = DATASETS.find(
|
||||
(candidate) => candidate.extended_pan_id === parsed.extendedPanId
|
||||
);
|
||||
if (existing) {
|
||||
const current = parseDatasetTlv(DATASET_TLVS[existing.dataset_id]);
|
||||
if (current && parsed.activeTimestamp <= current.activeTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
existing.channel = parsed.channel;
|
||||
existing.network_name = parsed.networkName;
|
||||
existing.pan_id = parsed.panId;
|
||||
DATASET_TLVS[existing.dataset_id] = msg.tlv.toUpperCase();
|
||||
return undefined;
|
||||
}
|
||||
added += 1;
|
||||
const dataset: ThreadDataSet = {
|
||||
channel: parsed.channel,
|
||||
created: new Date().toISOString(),
|
||||
dataset_id: `added-dataset-${added}`,
|
||||
extended_pan_id: parsed.extendedPanId,
|
||||
network_name: parsed.networkName,
|
||||
pan_id: parsed.panId,
|
||||
preferred_border_agent_id: null,
|
||||
preferred_extended_address: null,
|
||||
preferred: false,
|
||||
source: msg.source,
|
||||
};
|
||||
DATASETS.push(dataset);
|
||||
DATASET_TLVS[dataset.dataset_id] = msg.tlv.toUpperCase();
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("thread/delete_dataset", (msg: { dataset_id: string }) => {
|
||||
const index = DATASETS.findIndex(
|
||||
(dataset) => dataset.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (index === -1) {
|
||||
throw new Error(`Dataset ${msg.dataset_id} not found`);
|
||||
}
|
||||
if (DATASETS[index].preferred) {
|
||||
throw new Error("Preferred dataset cannot be deleted");
|
||||
}
|
||||
DATASETS.splice(index, 1);
|
||||
delete DATASET_TLVS[msg.dataset_id];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS("thread/set_preferred_dataset", (msg: { dataset_id: string }) => {
|
||||
DATASETS.forEach((dataset) => {
|
||||
dataset.preferred = dataset.dataset_id === msg.dataset_id;
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"thread/set_preferred_border_agent",
|
||||
(msg: {
|
||||
dataset_id: string;
|
||||
border_agent_id: string | null;
|
||||
extended_address: string;
|
||||
}) => {
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (dataset) {
|
||||
dataset.preferred_border_agent_id = msg.border_agent_id;
|
||||
dataset.preferred_extended_address = msg.extended_address;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("otbr/create_network", (msg: { extended_address: string }) => {
|
||||
created += 1;
|
||||
const dataset: ThreadDataSet = {
|
||||
channel: 15,
|
||||
created: new Date().toISOString(),
|
||||
dataset_id: `created-dataset-${created}`,
|
||||
extended_pan_id: randomExtendedPanId(),
|
||||
network_name: `ha-thread-${created}`,
|
||||
pan_id: "1234",
|
||||
preferred_border_agent_id: null,
|
||||
preferred_extended_address: null,
|
||||
preferred: false,
|
||||
source: "otbr",
|
||||
};
|
||||
DATASETS.push(dataset);
|
||||
DATASET_TLVS[dataset.dataset_id] = buildDatasetTlv(dataset);
|
||||
moveRouter(
|
||||
msg.extended_address,
|
||||
dataset.extended_pan_id,
|
||||
dataset.network_name,
|
||||
dataset.dataset_id
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"otbr/set_network",
|
||||
(msg: { extended_address: string; dataset_id: string }) => {
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (dataset) {
|
||||
const info = OTBR_INFO[msg.extended_address];
|
||||
if (info) {
|
||||
info.channel = dataset.channel ?? info.channel;
|
||||
}
|
||||
moveRouter(
|
||||
msg.extended_address,
|
||||
dataset.extended_pan_id,
|
||||
dataset.network_name,
|
||||
dataset.dataset_id
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"otbr/set_channel",
|
||||
(msg: { extended_address: string; channel: number }) => {
|
||||
const info = OTBR_INFO[msg.extended_address];
|
||||
if (info) {
|
||||
info.channel = msg.channel;
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.extended_pan_id === info.extended_pan_id
|
||||
);
|
||||
if (dataset) {
|
||||
dataset.channel = msg.channel;
|
||||
DATASET_TLVS[dataset.dataset_id] = replaceTlvField(
|
||||
DATASET_TLVS[dataset.dataset_id],
|
||||
0x00,
|
||||
`00${msg.channel.toString(16).padStart(4, "0")}`
|
||||
);
|
||||
info.active_dataset_tlvs = DATASET_TLVS[dataset.dataset_id];
|
||||
}
|
||||
}
|
||||
return { delay: 120 };
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ConfigEntry } from "../../../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type {
|
||||
IntegrationManifest,
|
||||
IntegrationType,
|
||||
} from "../../../../src/data/integration";
|
||||
import type { TranslationCategory } from "../../../../src/data/translation";
|
||||
import type { EntityInput } from "../../../../src/fake_data/entities/types";
|
||||
|
||||
export interface DemoConfigEntry {
|
||||
entry: ConfigEntry;
|
||||
type: IntegrationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one connectivity integration contributes to the demo besides its
|
||||
* WebSocket mocks. This is loaded eagerly, together with the registries, so it
|
||||
* must not pull in the mocks (which are code-split into the config panel
|
||||
* chunk). Each integration owns one of these, so they stay independent.
|
||||
*/
|
||||
export interface ConnectivityFixtures {
|
||||
/** Components to load, so the integration's panel is reachable. */
|
||||
components: string[];
|
||||
/** WS command prefixes served by the integration's mock, if it has one. */
|
||||
commands?: string[];
|
||||
configEntries?: DemoConfigEntry[];
|
||||
/** Manifests for the domains above, so their integration pages open. */
|
||||
manifests?: IntegrationManifest[];
|
||||
devices?: DeviceRegistryEntry[];
|
||||
entityRegistryEntries?: EntityRegistryEntry[];
|
||||
/** States for the entities above; built lazily so timestamps stay fresh. */
|
||||
entities?: () => EntityInput[];
|
||||
/** Backend translations the panel looks up, by category. */
|
||||
backendTranslations?: Partial<
|
||||
Record<TranslationCategory, Record<string, string>>
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-zha";
|
||||
|
||||
export const COORDINATOR_IEEE = "00:12:4b:00:24:c2:e1:00";
|
||||
export const PORCH_IEEE = "84:2e:14:ff:fe:11:22:33";
|
||||
export const MOTION_IEEE = "cc:cc:cc:ff:fe:44:55:66";
|
||||
export const PLUG_IEEE = "00:15:8d:00:03:aa:bb:cc";
|
||||
export const KITCHEN_IEEE = "84:2e:14:ff:fe:aa:bb:01";
|
||||
export const LANDING_IEEE = "00:15:8d:00:03:aa:bb:02";
|
||||
export const GARAGE_IEEE = "00:15:8d:00:03:aa:bb:03";
|
||||
export const OFFICE_IEEE = "00:15:8d:00:03:aa:bb:04";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"zha-coordinator",
|
||||
"Home Assistant Connect ZBT-1",
|
||||
"Nabu Casa",
|
||||
"Connect ZBT-1",
|
||||
ENTRY_ID,
|
||||
{
|
||||
sw_version: "7.4.4.0",
|
||||
connections: [["zigbee", COORDINATOR_IEEE]],
|
||||
identifiers: [["zha", COORDINATOR_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-porch-light",
|
||||
"Porch light",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI bulb E27 CWS 806lm",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", PORCH_IEEE]],
|
||||
identifiers: [["zha", PORCH_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-hall-motion",
|
||||
"Hall motion",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI motion sensor",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", MOTION_IEEE]],
|
||||
identifiers: [["zha", MOTION_IEEE]],
|
||||
}
|
||||
),
|
||||
device("zha-tv-plug", "TV plug", "Innr", "SP 220", ENTRY_ID, {
|
||||
area_id: "living_room",
|
||||
connections: [["zigbee", PLUG_IEEE]],
|
||||
identifiers: [["zha", PLUG_IEEE]],
|
||||
}),
|
||||
device(
|
||||
"zha-kitchen-switch",
|
||||
"Kitchen switch",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI on/off switch",
|
||||
ENTRY_ID,
|
||||
{
|
||||
area_id: "kitchen",
|
||||
connections: [["zigbee", KITCHEN_IEEE]],
|
||||
identifiers: [["zha", KITCHEN_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-landing-sensor",
|
||||
"Landing sensor",
|
||||
"Aqara",
|
||||
"WSDCGQ11LM",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", LANDING_IEEE]],
|
||||
identifiers: [["zha", LANDING_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-garage-contact",
|
||||
"Garage contact",
|
||||
"Aqara",
|
||||
"MCCGQ11LM",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", GARAGE_IEEE]],
|
||||
identifiers: [["zha", GARAGE_IEEE]],
|
||||
}
|
||||
),
|
||||
device("zha-office-plug", "Office plug", "Innr", "SP 240", ENTRY_ID, {
|
||||
area_id: "office",
|
||||
connections: [["zigbee", OFFICE_IEEE]],
|
||||
identifiers: [["zha", OFFICE_IEEE]],
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry("light.porch", "zha-porch-light", ENTRY_ID, "zha"),
|
||||
registryEntry(
|
||||
"binary_sensor.hall_motion",
|
||||
"zha-hall-motion",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry("switch.tv_plug", "zha-tv-plug", ENTRY_ID, "zha"),
|
||||
registryEntry(
|
||||
"sensor.kitchen_switch_battery",
|
||||
"zha-kitchen-switch",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.landing_temperature",
|
||||
"zha-landing-sensor",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry(
|
||||
"binary_sensor.garage_contact",
|
||||
"zha-garage-contact",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry("switch.office_desk", "zha-office-plug", ENTRY_ID, "zha"),
|
||||
];
|
||||
|
||||
export const AREA_BY_IEEE: Record<string, string> = Object.fromEntries(
|
||||
DEVICES.flatMap((entry) => {
|
||||
const areaId = entry.area_id;
|
||||
return areaId
|
||||
? entry.connections
|
||||
.filter(([type]) => type === "zigbee")
|
||||
.map(([, ieee]) => [ieee, areaId])
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
export const zhaFixtures: ConnectivityFixtures = {
|
||||
components: ["zha"],
|
||||
commands: ["zha/"],
|
||||
manifests: [
|
||||
manifest("zha", "Zigbee Home Automation", {
|
||||
integration_type: "hub",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "zha", "Home Assistant Connect ZBT-1", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.porch": {
|
||||
entity_id: "light.porch",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Porch light",
|
||||
supported_color_modes: ["hs"],
|
||||
},
|
||||
},
|
||||
"binary_sensor.hall_motion": {
|
||||
entity_id: "binary_sensor.hall_motion",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Hall motion", device_class: "motion" },
|
||||
},
|
||||
"switch.tv_plug": {
|
||||
entity_id: "switch.tv_plug",
|
||||
state: "unavailable",
|
||||
attributes: { friendly_name: "TV plug" },
|
||||
},
|
||||
"sensor.kitchen_switch_battery": {
|
||||
entity_id: "sensor.kitchen_switch_battery",
|
||||
state: "78",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen switch battery",
|
||||
device_class: "battery",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"sensor.landing_temperature": {
|
||||
entity_id: "sensor.landing_temperature",
|
||||
state: "19.6",
|
||||
attributes: {
|
||||
friendly_name: "Landing temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"binary_sensor.garage_contact": {
|
||||
entity_id: "binary_sensor.garage_contact",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Garage contact", device_class: "door" },
|
||||
},
|
||||
"switch.office_desk": {
|
||||
entity_id: "switch.office_desk",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Office desk" },
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
config_panel: {
|
||||
"component.zha.config_panel.zha_options.title": "Global options",
|
||||
"component.zha.config_panel.zha_options.default_light_transition":
|
||||
"Default light transition time (seconds)",
|
||||
"component.zha.config_panel.zha_options.enhanced_light_transition":
|
||||
"Enable enhanced light color/temperature transition from an off state",
|
||||
"component.zha.config_panel.zha_options.always_prefer_xy_color_mode":
|
||||
"Always prefer XY color mode",
|
||||
"component.zha.config_panel.zha_alarm_options.title": "Alarm options",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_master_code":
|
||||
"Alarm master code",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_failed_tries":
|
||||
"Failed authentication attempts before restart",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_arm_requires_code":
|
||||
"Code required for arming",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,793 @@
|
||||
import type {
|
||||
Attribute,
|
||||
AttributeConfigurationStatus,
|
||||
Cluster,
|
||||
ClusterConfigurationEvent,
|
||||
Command,
|
||||
Neighbor,
|
||||
ReadAttributeServiceData,
|
||||
ZHAConfiguration,
|
||||
ZHADevice,
|
||||
ZHADeviceEndpoint,
|
||||
ZHAGroup,
|
||||
ZHAEntityReference,
|
||||
ZHAGroupMember,
|
||||
ZHANetworkBackup,
|
||||
ZHANetworkSettings,
|
||||
} from "../../../../../src/data/zha";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { minutesAgo } from "../helpers";
|
||||
|
||||
import {
|
||||
AREA_BY_IEEE,
|
||||
LANDING_IEEE,
|
||||
COORDINATOR_IEEE,
|
||||
GARAGE_IEEE,
|
||||
KITCHEN_IEEE,
|
||||
MOTION_IEEE,
|
||||
OFFICE_IEEE,
|
||||
PLUG_IEEE,
|
||||
PORCH_IEEE,
|
||||
} from "./fixtures";
|
||||
|
||||
const neighbor = (
|
||||
ieee: string,
|
||||
nwk: string,
|
||||
lqi: string,
|
||||
relationship: string,
|
||||
depth = "1"
|
||||
): Neighbor => ({ ieee, nwk, lqi, depth, relationship });
|
||||
|
||||
const DEVICES: ZHADevice[] = [
|
||||
{
|
||||
available: true,
|
||||
name: "Nabu Casa Connect ZBT-1",
|
||||
ieee: COORDINATOR_IEEE,
|
||||
nwk: 0x0000,
|
||||
lqi: 255,
|
||||
rssi: "0",
|
||||
last_seen: minutesAgo(0),
|
||||
manufacturer: "Nabu Casa",
|
||||
model: "Connect ZBT-1",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-coordinator",
|
||||
user_given_name: "Home Assistant Connect ZBT-1",
|
||||
power_source: "Mains",
|
||||
device_type: "Coordinator",
|
||||
active_coordinator: true,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(PORCH_IEEE, "0x1a2b", "224", "Child"),
|
||||
neighbor(PLUG_IEEE, "0x3c4d", "198", "Child"),
|
||||
neighbor(OFFICE_IEEE, "0x7a8b", "211", "Child"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI bulb E27 CWS 806lm",
|
||||
ieee: PORCH_IEEE,
|
||||
nwk: 0x1a2b,
|
||||
lqi: 224,
|
||||
rssi: "-58",
|
||||
last_seen: minutesAgo(2),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI bulb E27 CWS 806lm",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.ikea.bulb.IkeaBulb",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-porch-light",
|
||||
user_given_name: "Porch light",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(COORDINATOR_IEEE, "0x0000", "224", "Parent", "0"),
|
||||
neighbor(MOTION_IEEE, "0x5e6f", "142", "Child", "2"),
|
||||
neighbor(KITCHEN_IEEE, "0x9c0d", "186", "Sibling", "1"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI motion sensor",
|
||||
ieee: MOTION_IEEE,
|
||||
nwk: 0x5e6f,
|
||||
lqi: 142,
|
||||
rssi: "-77",
|
||||
last_seen: minutesAgo(9),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI motion sensor",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-hall-motion",
|
||||
user_given_name: "Hall motion",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: false,
|
||||
name: "Innr SP 220",
|
||||
ieee: PLUG_IEEE,
|
||||
nwk: 0x3c4d,
|
||||
lqi: 198,
|
||||
rssi: "-64",
|
||||
last_seen: minutesAgo(240),
|
||||
manufacturer: "Innr",
|
||||
model: "SP 220",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4448,
|
||||
device_reg_id: "zha-tv-plug",
|
||||
user_given_name: "TV plug",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI on/off switch",
|
||||
ieee: KITCHEN_IEEE,
|
||||
nwk: 0x9c0d,
|
||||
lqi: 186,
|
||||
rssi: "-69",
|
||||
last_seen: minutesAgo(4),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI on/off switch",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.ikea.onoffswitch.IkeaSwitch",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-kitchen-switch",
|
||||
user_given_name: "Kitchen switch",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Aqara temperature sensor",
|
||||
ieee: LANDING_IEEE,
|
||||
nwk: 0xab12,
|
||||
lqi: 164,
|
||||
rssi: "-74",
|
||||
last_seen: minutesAgo(6),
|
||||
manufacturer: "Aqara",
|
||||
model: "WSDCGQ11LM",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.xiaomi.aqara.weather.Weather",
|
||||
entities: [],
|
||||
manufacturer_code: 4447,
|
||||
device_reg_id: "zha-landing-sensor",
|
||||
user_given_name: "Landing sensor",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Aqara door sensor",
|
||||
ieee: GARAGE_IEEE,
|
||||
nwk: 0xcd34,
|
||||
lqi: 118,
|
||||
rssi: "-83",
|
||||
last_seen: minutesAgo(21),
|
||||
manufacturer: "Aqara",
|
||||
model: "MCCGQ11LM",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.xiaomi.aqara.magnet.Magnet",
|
||||
entities: [],
|
||||
manufacturer_code: 4447,
|
||||
device_reg_id: "zha-garage-contact",
|
||||
user_given_name: "Garage contact",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Innr SP 240",
|
||||
ieee: OFFICE_IEEE,
|
||||
nwk: 0x7a8b,
|
||||
lqi: 211,
|
||||
rssi: "-61",
|
||||
last_seen: minutesAgo(1),
|
||||
manufacturer: "Innr",
|
||||
model: "SP 240",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4448,
|
||||
device_reg_id: "zha-office-plug",
|
||||
user_given_name: "Office plug",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(COORDINATOR_IEEE, "0x0000", "211", "Parent", "0"),
|
||||
neighbor(LANDING_IEEE, "0xab12", "164", "Child", "2"),
|
||||
neighbor(GARAGE_IEEE, "0xcd34", "118", "Child", "2"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
];
|
||||
|
||||
DEVICES.forEach((zhaDevice) => {
|
||||
zhaDevice.area_id = AREA_BY_IEEE[zhaDevice.ieee];
|
||||
});
|
||||
|
||||
const deviceByIeee = (ieee: string): ZHADevice =>
|
||||
DEVICES.find((d) => d.ieee === ieee)!;
|
||||
|
||||
const ENDPOINT_ENTITIES: Record<string, { entity_id: string; name: string }[]> =
|
||||
{
|
||||
[PORCH_IEEE]: [{ entity_id: "light.porch", name: "Porch light" }],
|
||||
[PLUG_IEEE]: [{ entity_id: "switch.tv_plug", name: "TV plug" }],
|
||||
[OFFICE_IEEE]: [{ entity_id: "switch.office_desk", name: "Office desk" }],
|
||||
};
|
||||
|
||||
const member = (ieee: string, endpointId = 1): ZHADeviceEndpoint => ({
|
||||
device: deviceByIeee(ieee),
|
||||
endpoint_id: endpointId,
|
||||
entities: (ENDPOINT_ENTITIES[ieee] ?? []).map(
|
||||
(entity) =>
|
||||
({
|
||||
...entity,
|
||||
original_name: entity.name,
|
||||
}) as ZHAEntityReference
|
||||
),
|
||||
});
|
||||
|
||||
const GROUPS: ZHAGroup[] = [
|
||||
{
|
||||
name: "Downstairs lights",
|
||||
group_id: 1,
|
||||
members: [member(PORCH_IEEE), member(OFFICE_IEEE)],
|
||||
},
|
||||
{
|
||||
name: "Outdoor lights",
|
||||
group_id: 2,
|
||||
members: [member(PORCH_IEEE)],
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGURATION: ZHAConfiguration = {
|
||||
data: {
|
||||
zha_options: {
|
||||
default_light_transition: 0,
|
||||
enhanced_light_transition: false,
|
||||
light_transitioning_flag: true,
|
||||
always_prefer_xy_color_mode: true,
|
||||
group_members_assume_state: true,
|
||||
consider_unavailable_mains: 7200,
|
||||
consider_unavailable_battery: 21600,
|
||||
},
|
||||
zha_alarm_options: {
|
||||
alarm_master_code: "1234",
|
||||
alarm_failed_tries: 3,
|
||||
alarm_arm_requires_code: false,
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
zha_options: [
|
||||
{
|
||||
name: "default_light_transition",
|
||||
required: true,
|
||||
selector: { number: { min: 0, max: 2 ** 16 / 10, step: 0.1 } },
|
||||
},
|
||||
{
|
||||
name: "enhanced_light_transition",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "always_prefer_xy_color_mode",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
zha_alarm_options: [
|
||||
{ name: "alarm_master_code", required: true, selector: { text: {} } },
|
||||
{
|
||||
name: "alarm_failed_tries",
|
||||
required: true,
|
||||
selector: { number: { min: 0, max: 2 ** 8, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "alarm_arm_requires_code",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const BACKUPS: ZHANetworkBackup[] = [];
|
||||
|
||||
const NETWORK_SETTINGS: ZHANetworkSettings = {
|
||||
radio_type: "ezsp",
|
||||
device: { path: "/dev/ttyUSB0", baudrate: 115200, flow_control: "hardware" },
|
||||
settings: {
|
||||
backup_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
node_info: {
|
||||
nwk: "0x0000",
|
||||
ieee: COORDINATOR_IEEE,
|
||||
logical_type: "coordinator",
|
||||
},
|
||||
network_info: {
|
||||
extended_pan_id: "b0:23:2f:cc:aa:11:22:33",
|
||||
pan_id: "0x1234",
|
||||
nwk_update_id: 0,
|
||||
nwk_manager_id: "0x0000",
|
||||
channel: 15,
|
||||
channel_mask: [15, 20, 25],
|
||||
security_level: 5,
|
||||
network_key: {
|
||||
key: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
tx_counter: 138234,
|
||||
rx_counter: 0,
|
||||
seq: 0,
|
||||
partner_ieee: "ff:ff:ff:ff:ff:ff:ff:ff",
|
||||
},
|
||||
tc_link_key: {
|
||||
key: "5a:69:67:42:65:65:41:6c:6c:69:61:6e:63:65:30:39",
|
||||
tx_counter: 0,
|
||||
rx_counter: 0,
|
||||
seq: 0,
|
||||
partner_ieee: COORDINATOR_IEEE,
|
||||
},
|
||||
key_table: [],
|
||||
children: [PORCH_IEEE, PLUG_IEEE, OFFICE_IEEE],
|
||||
nwk_addresses: {
|
||||
[PORCH_IEEE]: "0x1a2b",
|
||||
[MOTION_IEEE]: "0x5e6f",
|
||||
[PLUG_IEEE]: "0x3c4d",
|
||||
[KITCHEN_IEEE]: "0x9c0d",
|
||||
[LANDING_IEEE]: "0xab12",
|
||||
[GARAGE_IEEE]: "0xcd34",
|
||||
[OFFICE_IEEE]: "0x7a8b",
|
||||
},
|
||||
stack_specific: {},
|
||||
metadata: { ezsp: { stack_version: "7.4.4.0" } },
|
||||
source: "[email protected]",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface ClusterDefinition {
|
||||
name: string;
|
||||
attributes: Attribute[];
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
const numberField = (name: string, max: number) => ({
|
||||
name,
|
||||
required: true,
|
||||
selector: { number: { min: 0, max, mode: "box" as const } },
|
||||
});
|
||||
|
||||
const CLUSTERS: Record<number, ClusterDefinition> = {
|
||||
0: {
|
||||
name: "Basic",
|
||||
attributes: [
|
||||
{ name: "zcl_version", id: 0 },
|
||||
{ name: "app_version", id: 1 },
|
||||
{ name: "manufacturer", id: 4 },
|
||||
{ name: "model", id: 5 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
1: {
|
||||
name: "PowerConfiguration",
|
||||
attributes: [
|
||||
{ name: "battery_voltage", id: 32 },
|
||||
{ name: "battery_percentage_remaining", id: 33 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
3: {
|
||||
name: "Identify",
|
||||
attributes: [{ name: "identify_time", id: 0 }],
|
||||
commands: [
|
||||
{
|
||||
name: "identify",
|
||||
id: 0,
|
||||
type: "server",
|
||||
schema: [numberField("identify_time", 65535)],
|
||||
},
|
||||
],
|
||||
},
|
||||
4: {
|
||||
name: "Groups",
|
||||
attributes: [{ name: "name_support", id: 0 }],
|
||||
commands: [],
|
||||
},
|
||||
6: {
|
||||
name: "OnOff",
|
||||
attributes: [{ name: "on_off", id: 0 }],
|
||||
commands: [
|
||||
{ name: "off", id: 0, type: "server", schema: [] },
|
||||
{ name: "on", id: 1, type: "server", schema: [] },
|
||||
{ name: "toggle", id: 2, type: "server", schema: [] },
|
||||
],
|
||||
},
|
||||
8: {
|
||||
name: "LevelControl",
|
||||
attributes: [{ name: "current_level", id: 0 }],
|
||||
commands: [
|
||||
{
|
||||
name: "move_to_level",
|
||||
id: 0,
|
||||
type: "server",
|
||||
schema: [
|
||||
numberField("level", 254),
|
||||
numberField("transition_time", 65535),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
25: {
|
||||
name: "Ota",
|
||||
attributes: [{ name: "current_file_version", id: 2 }],
|
||||
commands: [],
|
||||
},
|
||||
768: {
|
||||
name: "ColorControl",
|
||||
attributes: [
|
||||
{ name: "current_hue", id: 0 },
|
||||
{ name: "current_saturation", id: 1 },
|
||||
{ name: "color_temperature", id: 7 },
|
||||
],
|
||||
commands: [
|
||||
{
|
||||
name: "move_to_color_temp",
|
||||
id: 10,
|
||||
type: "server",
|
||||
schema: [
|
||||
numberField("color_temp_mireds", 500),
|
||||
numberField("transition_time", 65535),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
1026: {
|
||||
name: "TemperatureMeasurement",
|
||||
attributes: [
|
||||
{ name: "measured_value", id: 0 },
|
||||
{ name: "min_measured_value", id: 1 },
|
||||
{ name: "max_measured_value", id: 2 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
1280: {
|
||||
name: "IasZone",
|
||||
attributes: [
|
||||
{ name: "zone_state", id: 0 },
|
||||
{ name: "zone_type", id: 1 },
|
||||
{ name: "zone_status", id: 2 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
2820: {
|
||||
name: "ElectricalMeasurement",
|
||||
attributes: [
|
||||
{ name: "rms_voltage", id: 1285 },
|
||||
{ name: "rms_current", id: 1288 },
|
||||
{ name: "active_power", id: 1291 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
};
|
||||
|
||||
const clusterList = (inIds: number[], outIds: number[] = []): Cluster[] =>
|
||||
[
|
||||
...inIds.map((id) => ({ id, type: "in" })),
|
||||
...outIds.map((id) => ({ id, type: "out" })),
|
||||
].map(({ id, type }) => ({
|
||||
name: CLUSTERS[id].name,
|
||||
id,
|
||||
endpoint_id: 1,
|
||||
type,
|
||||
}));
|
||||
|
||||
// A device binds from its client side, and group binding lists only those, so
|
||||
// the remotes carry the `out` clusters they would have on real hardware and
|
||||
// the mains-powered devices only their OTA one.
|
||||
const DEVICE_CLUSTERS: Record<string, Cluster[]> = {
|
||||
[COORDINATOR_IEEE]: clusterList([0]),
|
||||
[PORCH_IEEE]: clusterList([0, 3, 4, 6, 8, 768], [25]),
|
||||
[MOTION_IEEE]: clusterList([0, 1, 3, 1280], [3, 6, 8]),
|
||||
[PLUG_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
|
||||
[KITCHEN_IEEE]: clusterList([0, 1, 3], [3, 6, 8]),
|
||||
[LANDING_IEEE]: clusterList([0, 1, 3, 1026]),
|
||||
[GARAGE_IEEE]: clusterList([0, 1, 3, 1280]),
|
||||
[OFFICE_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
|
||||
};
|
||||
|
||||
const DEFAULT_ATTRIBUTE_VALUES: Record<string, string> = {
|
||||
"1:32": "30",
|
||||
"1:33": "184",
|
||||
"3:0": "0",
|
||||
"4:0": "0",
|
||||
"6:0": "1",
|
||||
"8:0": "254",
|
||||
"768:0": "42",
|
||||
"768:1": "180",
|
||||
"768:7": "370",
|
||||
"1026:0": "2140",
|
||||
"1026:1": "-2000",
|
||||
"1026:2": "6000",
|
||||
"1280:0": "1",
|
||||
"1280:1": "21",
|
||||
"1280:2": "0",
|
||||
"2820:1285": "2300",
|
||||
"2820:1288": "410",
|
||||
"2820:1291": "94",
|
||||
};
|
||||
|
||||
const writtenAttributes = new Map<string, string>();
|
||||
|
||||
const attributeKey = (data: ReadAttributeServiceData) =>
|
||||
`${data.ieee}:${data.endpoint_id}:${data.cluster_id}:${data.attribute}`;
|
||||
|
||||
const attributeValue = (data: ReadAttributeServiceData): string => {
|
||||
const written = writtenAttributes.get(attributeKey(data));
|
||||
if (written !== undefined) {
|
||||
return written;
|
||||
}
|
||||
if (data.cluster_id === 0) {
|
||||
const device = DEVICES.find((candidate) => candidate.ieee === data.ieee);
|
||||
if (data.attribute === 4) {
|
||||
return device?.manufacturer ?? "";
|
||||
}
|
||||
if (data.attribute === 5) {
|
||||
return device?.model ?? "";
|
||||
}
|
||||
return "3";
|
||||
}
|
||||
return (
|
||||
DEFAULT_ATTRIBUTE_VALUES[`${data.cluster_id}:${data.attribute}`] ?? "0"
|
||||
);
|
||||
};
|
||||
|
||||
export const mockZha = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zha/devices", () => DEVICES);
|
||||
hass.mockWS("zha/device", (msg: { ieee: string }) =>
|
||||
DEVICES.find((device) => device.ieee === msg.ieee)
|
||||
);
|
||||
hass.mockWS("zha/groups", () => GROUPS);
|
||||
hass.mockWS("zha/group", (msg: { group_id: number }) =>
|
||||
GROUPS.find((group) => group.group_id === msg.group_id)
|
||||
);
|
||||
// Copied: both options editors mutate the fetched data as the user changes a
|
||||
// control, so handing out the backing object would persist edits that were
|
||||
// never saved. Only the update below writes to it.
|
||||
hass.mockWS("zha/configuration", () => structuredClone(CONFIGURATION));
|
||||
hass.mockWS("zha/network/settings", () => NETWORK_SETTINGS);
|
||||
hass.mockWS("zha/topology/update", () => undefined);
|
||||
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
|
||||
DEVICES.filter(
|
||||
(candidate) =>
|
||||
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
|
||||
)
|
||||
);
|
||||
hass.mockWS("zha/devices/bind", () => undefined);
|
||||
hass.mockWS("zha/devices/unbind", () => undefined);
|
||||
hass.mockWS("zha/groups/bind", () => undefined);
|
||||
hass.mockWS("zha/groups/unbind", () => undefined);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters",
|
||||
(msg: { ieee: string }) => DEVICE_CLUSTERS[msg.ieee] ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/attributes",
|
||||
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.attributes ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/commands",
|
||||
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.commands ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/attributes/value",
|
||||
(msg: ReadAttributeServiceData) => attributeValue(msg)
|
||||
);
|
||||
hass.mockService("zha", "set_zigbee_cluster_attribute", (data) => {
|
||||
const write = data as ReadAttributeServiceData & { value: unknown };
|
||||
writtenAttributes.set(attributeKey(write), String(write.value));
|
||||
return undefined;
|
||||
});
|
||||
hass.mockWS("zha/devices/groupable", () => [
|
||||
member(PORCH_IEEE),
|
||||
member(OFFICE_IEEE),
|
||||
member(PLUG_IEEE),
|
||||
]);
|
||||
hass.mockWS("zha/network/backups/list", () => BACKUPS);
|
||||
|
||||
hass.mockWS("zha/devices/permit", () => () => undefined);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/devices/reconfigure",
|
||||
(msg: { ieee: string }, _hass, onChange) => {
|
||||
const deviceClusters = DEVICE_CLUSTERS[msg.ieee] ?? [];
|
||||
const timers: number[] = [];
|
||||
let cancelled = false;
|
||||
const emit = (event: ClusterConfigurationEvent, step: number) => {
|
||||
timers.push(
|
||||
window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
onChange!(event);
|
||||
}
|
||||
}, step * 400)
|
||||
);
|
||||
};
|
||||
|
||||
deviceClusters.forEach((cluster, index) => {
|
||||
emit(
|
||||
{
|
||||
type: "zha_channel_bind",
|
||||
zha_channel_msg_data: {
|
||||
cluster_name: cluster.name,
|
||||
cluster_id: cluster.id,
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
index + 1
|
||||
);
|
||||
const attributes: AttributeConfigurationStatus[] = CLUSTERS[
|
||||
cluster.id
|
||||
].attributes.map((attribute) => ({
|
||||
...attribute,
|
||||
status: "SUCCESS",
|
||||
min: 30,
|
||||
max: 900,
|
||||
change: 1,
|
||||
}));
|
||||
if (attributes.length) {
|
||||
emit(
|
||||
{
|
||||
type: "zha_channel_configure_reporting",
|
||||
zha_channel_msg_data: {
|
||||
cluster_name: cluster.name,
|
||||
cluster_id: cluster.id,
|
||||
attributes,
|
||||
},
|
||||
},
|
||||
index + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
emit({ type: "zha_channel_cfg_done" }, deviceClusters.length + 1);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/configuration/update",
|
||||
(msg: { data: ZHAConfiguration["data"] }) => {
|
||||
Object.entries(msg.data ?? {}).forEach(([section, values]) => {
|
||||
CONFIGURATION.data[section] = {
|
||||
...CONFIGURATION.data[section],
|
||||
...values,
|
||||
};
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("zha/network/backups/create", () => {
|
||||
const backup: ZHANetworkBackup = {
|
||||
backup_time: new Date().toISOString(),
|
||||
// Copied, or changing the channel afterwards would rewrite the backup
|
||||
// too, which is the one thing a backup must not do.
|
||||
network_info: structuredClone(NETWORK_SETTINGS.settings.network_info),
|
||||
node_info: structuredClone(NETWORK_SETTINGS.settings.node_info),
|
||||
};
|
||||
BACKUPS.push(backup);
|
||||
return { backup, is_complete: true };
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"zha/network/change_channel",
|
||||
(msg: { new_channel: "auto" | number }) => {
|
||||
NETWORK_SETTINGS.settings.network_info.channel =
|
||||
msg.new_channel === "auto" ? 25 : msg.new_channel;
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/add",
|
||||
(msg: {
|
||||
group_name: string;
|
||||
group_id?: number;
|
||||
members?: ZHAGroupMember[];
|
||||
}) => {
|
||||
const group: ZHAGroup = {
|
||||
name: msg.group_name,
|
||||
group_id:
|
||||
msg.group_id ??
|
||||
GROUPS.reduce(
|
||||
(highest, item) => Math.max(highest, item.group_id),
|
||||
0
|
||||
) + 1,
|
||||
members: (msg.members ?? []).map((item) => member(item.ieee)),
|
||||
};
|
||||
GROUPS.push(group);
|
||||
return group;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("zha/group/remove", (msg: { group_ids: number[] }) => {
|
||||
msg.group_ids.forEach((groupId) => {
|
||||
const index = GROUPS.findIndex((group) => group.group_id === groupId);
|
||||
if (index !== -1) {
|
||||
GROUPS.splice(index, 1);
|
||||
}
|
||||
});
|
||||
return GROUPS;
|
||||
});
|
||||
|
||||
const findGroup = (groupId: number) => {
|
||||
const group = GROUPS.find((candidate) => candidate.group_id === groupId);
|
||||
if (!group) {
|
||||
throw new Error(`Group ${groupId} not found`);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/members/add",
|
||||
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
|
||||
const group = findGroup(msg.group_id);
|
||||
const known = new Set(group.members.map((item) => item.device.ieee));
|
||||
group.members = [
|
||||
...group.members,
|
||||
...msg.members
|
||||
.filter((item) => !known.has(item.ieee))
|
||||
.map((item) => member(item.ieee)),
|
||||
];
|
||||
return group;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/members/remove",
|
||||
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
|
||||
const group = findGroup(msg.group_id);
|
||||
const dropped = new Set(msg.members.map((item) => item.ieee));
|
||||
group.members = group.members.filter(
|
||||
(item) => !dropped.has(item.device.ieee)
|
||||
);
|
||||
return group;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-zwave-js";
|
||||
export const HOME_ID = 3245146787;
|
||||
|
||||
// Node IDs are carried in the `zwave_js` device identifiers, which is where the
|
||||
// panels read them back from.
|
||||
export const CONTROLLER_NODE_ID = 1;
|
||||
export const HALLWAY_NODE_ID = 7;
|
||||
export const DIMMER_NODE_ID = 12;
|
||||
export const MOTION_NODE_ID = 15;
|
||||
export const LOCK_NODE_ID = 18;
|
||||
export const THERMOSTAT_NODE_ID = 20;
|
||||
export const SENSOR_NODE_ID = 23;
|
||||
export const OUTLET_NODE_ID = 31;
|
||||
|
||||
export const DEVICE_IDS_BY_NODE_ID: Record<number, string> = {
|
||||
[CONTROLLER_NODE_ID]: "zwave-controller",
|
||||
[HALLWAY_NODE_ID]: "zwave-hallway-switch",
|
||||
[DIMMER_NODE_ID]: "zwave-dining-dimmer",
|
||||
[MOTION_NODE_ID]: "zwave-garage-motion",
|
||||
[LOCK_NODE_ID]: "zwave-back-door-lock",
|
||||
[THERMOSTAT_NODE_ID]: "zwave-bedroom-thermostat",
|
||||
[SENSOR_NODE_ID]: "zwave-basement-sensor",
|
||||
[OUTLET_NODE_ID]: "zwave-porch-outlet",
|
||||
};
|
||||
|
||||
const identifiers = (nodeId: number): [string, string][] => [
|
||||
["zwave_js", `${HOME_ID}-${nodeId}`],
|
||||
];
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"zwave-controller",
|
||||
"Z-Wave stick",
|
||||
"Zooz",
|
||||
"800 Series Z-Wave Long Range",
|
||||
ENTRY_ID,
|
||||
{ sw_version: "1.10", identifiers: identifiers(CONTROLLER_NODE_ID) }
|
||||
),
|
||||
device(
|
||||
"zwave-dining-dimmer",
|
||||
"Dining room dimmer",
|
||||
"Inovelli",
|
||||
"LZW31-SN",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(DIMMER_NODE_ID) }
|
||||
),
|
||||
device("zwave-back-door-lock", "Back door lock", "Yale", "YRD226", ENTRY_ID, {
|
||||
identifiers: identifiers(LOCK_NODE_ID),
|
||||
}),
|
||||
device(
|
||||
"zwave-basement-sensor",
|
||||
"Basement sensor",
|
||||
"Aeotec",
|
||||
"ZWA005 TriSensor",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(SENSOR_NODE_ID) }
|
||||
),
|
||||
device("zwave-hallway-switch", "Hallway switch", "Zooz", "ZEN76", ENTRY_ID, {
|
||||
area_id: "entrance",
|
||||
identifiers: identifiers(HALLWAY_NODE_ID),
|
||||
}),
|
||||
device(
|
||||
"zwave-garage-motion",
|
||||
"Garage motion",
|
||||
"Aeotec",
|
||||
"MultiSensor 7",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(MOTION_NODE_ID) }
|
||||
),
|
||||
device(
|
||||
"zwave-bedroom-thermostat",
|
||||
"Bedroom thermostat",
|
||||
"Honeywell",
|
||||
"T6 Pro",
|
||||
ENTRY_ID,
|
||||
{ area_id: "bedroom", identifiers: identifiers(THERMOSTAT_NODE_ID) }
|
||||
),
|
||||
device("zwave-porch-outlet", "Porch outlet", "Zooz", "ZEN15", ENTRY_ID, {
|
||||
identifiers: identifiers(OUTLET_NODE_ID),
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"light.dining_room",
|
||||
"zwave-dining-dimmer",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry("lock.back_door", "zwave-back-door-lock", ENTRY_ID, "zwave_js"),
|
||||
registryEntry(
|
||||
"sensor.basement_humidity",
|
||||
"zwave-basement-sensor",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry("light.hallway", "zwave-hallway-switch", ENTRY_ID, "zwave_js"),
|
||||
registryEntry(
|
||||
"binary_sensor.garage_motion",
|
||||
"zwave-garage-motion",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry(
|
||||
"climate.bedroom",
|
||||
"zwave-bedroom-thermostat",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry(
|
||||
"switch.porch_outlet",
|
||||
"zwave-porch-outlet",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
];
|
||||
|
||||
export const zwaveJsFixtures: ConnectivityFixtures = {
|
||||
components: ["zwave_js"],
|
||||
commands: ["zwave_js/"],
|
||||
manifests: [manifest("zwave_js", "Z-Wave", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "zwave_js", "Z-Wave", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.dining_room": {
|
||||
entity_id: "light.dining_room",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Dining room dimmer",
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 128,
|
||||
},
|
||||
},
|
||||
"lock.back_door": {
|
||||
entity_id: "lock.back_door",
|
||||
state: "unlocked",
|
||||
attributes: { friendly_name: "Back door lock" },
|
||||
},
|
||||
"sensor.basement_humidity": {
|
||||
entity_id: "sensor.basement_humidity",
|
||||
state: "58",
|
||||
attributes: {
|
||||
friendly_name: "Basement humidity",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"light.hallway": {
|
||||
entity_id: "light.hallway",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Hallway switch",
|
||||
supported_color_modes: ["onoff"],
|
||||
},
|
||||
},
|
||||
"binary_sensor.garage_motion": {
|
||||
entity_id: "binary_sensor.garage_motion",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Garage motion", device_class: "motion" },
|
||||
},
|
||||
"climate.bedroom": {
|
||||
entity_id: "climate.bedroom",
|
||||
state: "heat",
|
||||
attributes: {
|
||||
friendly_name: "Bedroom thermostat",
|
||||
hvac_modes: ["off", "heat"],
|
||||
current_temperature: 19.6,
|
||||
temperature: 20.5,
|
||||
min_temp: 7,
|
||||
max_temp: 30,
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
"switch.porch_outlet": {
|
||||
entity_id: "switch.porch_outlet",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Porch outlet" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import type {
|
||||
ZWaveJSController,
|
||||
ZWaveJSNetwork,
|
||||
ZWaveJSNodeStatisticsUpdatedMessage,
|
||||
ZWaveJSNodeStatus,
|
||||
ZwaveJSProvisioningEntry,
|
||||
} from "../../../../../src/data/zwave_js";
|
||||
import {
|
||||
NodeStatus,
|
||||
ProvisioningEntryStatus,
|
||||
SecurityClass,
|
||||
} from "../../../../../src/data/zwave_js";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
import {
|
||||
CONTROLLER_NODE_ID,
|
||||
DEVICE_IDS_BY_NODE_ID,
|
||||
DIMMER_NODE_ID,
|
||||
HALLWAY_NODE_ID,
|
||||
HOME_ID,
|
||||
LOCK_NODE_ID,
|
||||
MOTION_NODE_ID,
|
||||
OUTLET_NODE_ID,
|
||||
SENSOR_NODE_ID,
|
||||
THERMOSTAT_NODE_ID,
|
||||
} from "./fixtures";
|
||||
|
||||
const node = (
|
||||
nodeId: number,
|
||||
status: NodeStatus,
|
||||
overrides: Partial<ZWaveJSNodeStatus> = {}
|
||||
): ZWaveJSNodeStatus => ({
|
||||
node_id: nodeId,
|
||||
ready: true,
|
||||
status,
|
||||
is_secure: true,
|
||||
is_routing: true,
|
||||
zwave_plus_version: 2,
|
||||
highest_security_class: SecurityClass.S2_Authenticated,
|
||||
is_controller_node: false,
|
||||
has_firmware_update_cc: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const NODES: ZWaveJSNodeStatus[] = [
|
||||
node(CONTROLLER_NODE_ID, NodeStatus.Alive, {
|
||||
is_controller_node: true,
|
||||
highest_security_class: SecurityClass.S2_AccessControl,
|
||||
}),
|
||||
node(HALLWAY_NODE_ID, NodeStatus.Alive),
|
||||
node(DIMMER_NODE_ID, NodeStatus.Alive),
|
||||
node(MOTION_NODE_ID, NodeStatus.Asleep, { is_routing: false }),
|
||||
node(LOCK_NODE_ID, NodeStatus.Asleep, {
|
||||
is_routing: false,
|
||||
highest_security_class: SecurityClass.S2_AccessControl,
|
||||
}),
|
||||
node(THERMOSTAT_NODE_ID, NodeStatus.Alive),
|
||||
node(SENSOR_NODE_ID, NodeStatus.Dead, { is_routing: false }),
|
||||
node(OUTLET_NODE_ID, NodeStatus.Alive),
|
||||
];
|
||||
|
||||
const CONTROLLER: ZWaveJSController = {
|
||||
home_id: HOME_ID,
|
||||
sdk_version: "7.19.3",
|
||||
type: 1,
|
||||
own_node_id: CONTROLLER_NODE_ID,
|
||||
rf_region: null,
|
||||
is_primary: true,
|
||||
is_using_home_id_from_other_network: false,
|
||||
is_sis_present: true,
|
||||
was_real_primary: true,
|
||||
is_suc: true,
|
||||
// NodeType.Controller; the enum itself is not exported from data/zwave_js.
|
||||
node_type: 0 as ZWaveJSController["node_type"],
|
||||
firmware_version: "1.10",
|
||||
manufacturer_id: 634,
|
||||
product_id: 4,
|
||||
product_type: 3,
|
||||
supported_function_types: [],
|
||||
suc_node_id: CONTROLLER_NODE_ID,
|
||||
supports_timers: false,
|
||||
is_rebuilding_routes: false,
|
||||
// InclusionState.Idle
|
||||
inclusion_state: 0,
|
||||
nodes: NODES,
|
||||
supports_long_range: true,
|
||||
};
|
||||
|
||||
const NETWORK: ZWaveJSNetwork = {
|
||||
client: {
|
||||
state: "connected",
|
||||
ws_server_url: "ws://localhost:3000",
|
||||
server_version: "1.40.1",
|
||||
driver_version: "13.2.0",
|
||||
},
|
||||
controller: CONTROLLER,
|
||||
};
|
||||
|
||||
const PROVISIONING_ENTRIES: ZwaveJSProvisioningEntry[] = [
|
||||
{
|
||||
dsk: "51590-27189-49239-34778-15304-59293-52843-45852",
|
||||
securityClasses: [SecurityClass.S2_Authenticated],
|
||||
status: ProvisioningEntryStatus.Active,
|
||||
additionalProperties: {},
|
||||
manufacturer: "Zooz",
|
||||
label: "ZEN32 Scene Controller",
|
||||
},
|
||||
];
|
||||
|
||||
// Node IDs each node can reach directly. Only requested when the map's
|
||||
// neighbor overlay is toggled on.
|
||||
const NEIGHBORS: Record<number, number[]> = {
|
||||
[CONTROLLER_NODE_ID]: [HALLWAY_NODE_ID, DIMMER_NODE_ID, OUTLET_NODE_ID],
|
||||
[HALLWAY_NODE_ID]: [
|
||||
CONTROLLER_NODE_ID,
|
||||
DIMMER_NODE_ID,
|
||||
LOCK_NODE_ID,
|
||||
THERMOSTAT_NODE_ID,
|
||||
],
|
||||
[DIMMER_NODE_ID]: [
|
||||
CONTROLLER_NODE_ID,
|
||||
HALLWAY_NODE_ID,
|
||||
SENSOR_NODE_ID,
|
||||
OUTLET_NODE_ID,
|
||||
],
|
||||
[MOTION_NODE_ID]: [THERMOSTAT_NODE_ID],
|
||||
[LOCK_NODE_ID]: [HALLWAY_NODE_ID],
|
||||
[THERMOSTAT_NODE_ID]: [HALLWAY_NODE_ID, MOTION_NODE_ID],
|
||||
[SENSOR_NODE_ID]: [DIMMER_NODE_ID],
|
||||
[OUTLET_NODE_ID]: [CONTROLLER_NODE_ID, DIMMER_NODE_ID],
|
||||
};
|
||||
|
||||
// Route each node reports as its last working route back to the controller,
|
||||
// so the map can draw the mesh instead of a star.
|
||||
const ROUTES: Record<number, { repeaters: number[]; rssi: number }> = {
|
||||
[HALLWAY_NODE_ID]: { repeaters: [], rssi: -44 },
|
||||
[DIMMER_NODE_ID]: { repeaters: [], rssi: -48 },
|
||||
[OUTLET_NODE_ID]: { repeaters: [], rssi: -57 },
|
||||
[LOCK_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -72 },
|
||||
[THERMOSTAT_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -66 },
|
||||
[MOTION_NODE_ID]: {
|
||||
repeaters: [HALLWAY_NODE_ID, THERMOSTAT_NODE_ID],
|
||||
rssi: -79,
|
||||
},
|
||||
[SENSOR_NODE_ID]: { repeaters: [DIMMER_NODE_ID], rssi: -81 },
|
||||
};
|
||||
|
||||
const NODE_IDS_BY_DEVICE_ID: Record<string, number> = Object.fromEntries(
|
||||
Object.entries(DEVICE_IDS_BY_NODE_ID).map(([nodeId, deviceId]) => [
|
||||
deviceId,
|
||||
Number(nodeId),
|
||||
])
|
||||
);
|
||||
|
||||
const buildNodeStatistics = (
|
||||
nodeId: number
|
||||
): ZWaveJSNodeStatisticsUpdatedMessage => {
|
||||
const route = ROUTES[nodeId];
|
||||
return {
|
||||
event: "statistics updated",
|
||||
source: "node",
|
||||
nodeId,
|
||||
node_id: nodeId,
|
||||
commands_tx: 1200 + nodeId * 7,
|
||||
commands_rx: 980 + nodeId * 5,
|
||||
commands_dropped_tx: 0,
|
||||
commands_dropped_rx: nodeId === SENSOR_NODE_ID ? 4 : 0,
|
||||
timeout_response: 0,
|
||||
rtt: 24 + nodeId,
|
||||
rssi: route?.rssi ?? null,
|
||||
lwr: route
|
||||
? {
|
||||
protocol_data_rate: 3,
|
||||
repeaters: route.repeaters.map(
|
||||
(repeaterNodeId) => DEVICE_IDS_BY_NODE_ID[repeaterNodeId]
|
||||
),
|
||||
rssi: route.rssi,
|
||||
repeater_rssi: route.repeaters.map(() => -55),
|
||||
route_failed_between: null,
|
||||
}
|
||||
: null,
|
||||
nlwr: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockZwaveJs = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zwave_js/network_status", () => NETWORK);
|
||||
hass.mockWS("zwave_js/network_neighbors", () => NEIGHBORS);
|
||||
hass.mockWS("zwave_js/get_provisioning_entries", () => PROVISIONING_ENTRIES);
|
||||
hass.mockWS("zwave_js/data_collection_status", () => ({
|
||||
enabled: false,
|
||||
opted_in: false,
|
||||
}));
|
||||
hass.mockWS("zwave_js/subscribe_s2_inclusion", () => () => undefined);
|
||||
hass.mockWS("zwave_js/node_status", (msg: { device_id: string }) => {
|
||||
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
|
||||
return NODES.find((n) => n.node_id === nodeId) ?? NODES[0];
|
||||
});
|
||||
hass.mockWS(
|
||||
"zwave_js/subscribe_node_statistics",
|
||||
(msg: { device_id: string }, _hass, onChange) => {
|
||||
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
|
||||
if (nodeId === undefined || nodeId === CONTROLLER_NODE_ID) {
|
||||
return () => undefined;
|
||||
}
|
||||
return emitInitial(() => onChange?.(buildNodeStatistics(nodeId)));
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"zwave_js/subscribe_controller_statistics",
|
||||
(_msg, _hass, onChange) =>
|
||||
emitInitial(() =>
|
||||
onChange?.({
|
||||
event: "statistics updated",
|
||||
source: "controller",
|
||||
messages_tx: 18234,
|
||||
messages_rx: 17980,
|
||||
messages_dropped_tx: 2,
|
||||
messages_dropped_rx: 5,
|
||||
nak: 0,
|
||||
can: 3,
|
||||
timeout_ack: 1,
|
||||
timeout_response: 0,
|
||||
timeout_callback: 0,
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import { connectivityDevices } from "./connectivity/fixtures";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
@@ -86,4 +87,5 @@ export const demoDevices: DeviceRegistryEntry[] = [
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
...connectivityDevices,
|
||||
];
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
import { connectivityManifests } from "./connectivity/fixtures";
|
||||
import { manifest } from "./manifest";
|
||||
|
||||
const manifests: IntegrationManifest[] = [
|
||||
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
|
||||
@@ -62,11 +50,18 @@ const manifests: IntegrationManifest[] = [
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
...connectivityManifests,
|
||||
];
|
||||
|
||||
export const mockIntegration = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("manifest/list", () => manifests);
|
||||
hass.mockWS("manifest/get", (msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration)
|
||||
// Never answer with undefined: the integration page reads the manifest it
|
||||
// gets back without guarding, so an unlisted domain would throw. The real
|
||||
// backend always has a manifest for a domain that has config entries.
|
||||
hass.mockWS(
|
||||
"manifest/get",
|
||||
(msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration) ??
|
||||
manifest(msg.integration, msg.integration)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
|
||||
/**
|
||||
* Builds a demo integration manifest. Lives in its own module so both the
|
||||
* manifest registry and the per-integration fixtures that feed it can use it
|
||||
* without importing each other.
|
||||
*/
|
||||
export const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
+54
-2
@@ -1,6 +1,58 @@
|
||||
import type { Tag } from "../../../src/data/tag";
|
||||
import type { Tag, UpdateTagParams } from "../../../src/data/tag";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockTags = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("tag/list", () => [{ id: "my-tag", name: "My Tag" }] as Tag[]);
|
||||
const tags: Tag[] = [{ id: "my-tag", name: "My Tag" }];
|
||||
let created = 0;
|
||||
|
||||
const find = (tagId: string) => tags.find((tag) => tag.id === tagId);
|
||||
|
||||
hass.mockWS("tag/list", () => tags.map((tag) => ({ ...tag })));
|
||||
|
||||
hass.mockWS(
|
||||
"tag/create",
|
||||
(msg: UpdateTagParams & { tag_id?: string }): Tag => {
|
||||
if (msg.tag_id && find(msg.tag_id)) {
|
||||
throw new Error(`Tag ${msg.tag_id} already exists`);
|
||||
}
|
||||
let id = msg.tag_id;
|
||||
while (!id) {
|
||||
created += 1;
|
||||
id = find(`tag-${created}`) ? undefined : `tag-${created}`;
|
||||
}
|
||||
const tag: Tag = {
|
||||
id,
|
||||
name: msg.name,
|
||||
description: msg.description,
|
||||
};
|
||||
tags.push(tag);
|
||||
return { ...tag };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"tag/update",
|
||||
(msg: UpdateTagParams & { tag_id: string }): Tag => {
|
||||
const tag = find(msg.tag_id);
|
||||
if (!tag) {
|
||||
throw new Error(`Tag ${msg.tag_id} not found`);
|
||||
}
|
||||
if ("name" in msg) {
|
||||
tag.name = msg.name;
|
||||
}
|
||||
if ("description" in msg) {
|
||||
tag.description = msg.description;
|
||||
}
|
||||
return { ...tag };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("tag/delete", (msg: { tag_id: string }) => {
|
||||
const index = tags.findIndex((tag) => tag.id === msg.tag_id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Tag ${msg.tag_id} not found`);
|
||||
}
|
||||
tags.splice(index, 1);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityBackendTranslations } from "./connectivity/fixtures";
|
||||
|
||||
export const mockTranslations = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("frontend/get_translations", (
|
||||
/* msg: {language: string, category: string} */
|
||||
) => ({ resources: {} }));
|
||||
export const mockTranslations = (
|
||||
hass: MockHomeAssistant,
|
||||
localizePromise?: Promise<LocalizeFunc>
|
||||
) => {
|
||||
hass.mockWS(
|
||||
"frontend/get_translations",
|
||||
(msg: { language: string; category?: string }) => ({
|
||||
resources:
|
||||
(msg.category && connectivityBackendTranslations[msg.category]) || {},
|
||||
})
|
||||
);
|
||||
|
||||
// `hass.loadBackendTranslation` is a no-op in the mocked hass, so categories
|
||||
// that are only requested through it never reach the WebSocket mock above.
|
||||
// Seed every category into the resources, after the fragment translations so
|
||||
// this merges on top of them.
|
||||
(localizePromise ?? Promise.resolve()).then(() =>
|
||||
hass.addTranslations(
|
||||
Object.assign({}, ...Object.values(connectivityBackendTranslations))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -693,9 +693,7 @@ class HaGallery extends LitElement {
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
user-select: initial;
|
||||
--ha-sidebar-width: 300px;
|
||||
--ha-sidebar-expanded-width: 300px;
|
||||
--ha-sidebar-expanded-item-width: 292px;
|
||||
|
||||
@@ -16,6 +16,25 @@ const ENTITIES = [
|
||||
duration: "0:05:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.active_timer",
|
||||
state: "active",
|
||||
attributes: {
|
||||
friendly_name: "Active timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:10:00",
|
||||
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.paused_timer",
|
||||
state: "paused",
|
||||
attributes: {
|
||||
friendly_name: "Paused timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:03:21",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-timer")
|
||||
|
||||
+19
-21
@@ -47,12 +47,12 @@
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.2",
|
||||
"@codemirror/view": "6.43.11",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.6.0",
|
||||
"@formatjs/intl-datetimeformat": "7.6.1",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
@@ -85,12 +85,11 @@
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.3.2",
|
||||
"@tsparticles/preset-links": "4.3.2",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
@@ -101,16 +100,15 @@
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.1",
|
||||
"hls.js": "1.7.2",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.3.0",
|
||||
"js-yaml": "5.4.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -118,7 +116,7 @@
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.10",
|
||||
"marked": "18.0.11",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -150,13 +148,13 @@
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@lokalise/node-api": "16.4.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/core": "2.2.2",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -180,7 +178,7 @@
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.9.0",
|
||||
"eslint": "10.9.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -190,9 +188,9 @@
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.11.0",
|
||||
"globals": "17.12.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -202,12 +200,12 @@
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.3.0",
|
||||
"lint-staged": "17.4.1",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"minify-literals": "2.2.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
@@ -217,7 +215,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.11",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -235,6 +233,6 @@
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"volta": {
|
||||
"node": "24.19.0"
|
||||
"node": "24.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260826.4"
|
||||
version = "20260826.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -62,12 +62,16 @@ export function computeCssColor(color: string): string {
|
||||
/**
|
||||
* Get a color from document's styles
|
||||
* @param color - Named theme color (examples: `red`, `primary-text`)
|
||||
* @returns Resolved color; initial color if not found in document's styles
|
||||
* @param style - Styles to resolve against, defaults to the document body
|
||||
* @returns Resolved color; initial color if not found in the styles
|
||||
*/
|
||||
export function resolveThemeColor(color: string): string {
|
||||
export function resolveThemeColor(
|
||||
color: string,
|
||||
style?: CSSStyleDeclaration
|
||||
): string {
|
||||
const cssColor = computeCssVariableName(color);
|
||||
if (cssColor.startsWith("--")) {
|
||||
const resolved = getComputedStyle(document.body)
|
||||
const resolved = (style ?? getComputedStyle(document.body))
|
||||
.getPropertyValue(cssColor)
|
||||
.trim();
|
||||
return resolved || color;
|
||||
|
||||
+22
-5
@@ -1,4 +1,4 @@
|
||||
import { wcagLuminance, wcagContrast } from "culori";
|
||||
import { parse, wcagLuminance, wcagContrast } from "culori";
|
||||
import { theme2hex } from "./convert-color";
|
||||
|
||||
/**
|
||||
@@ -51,11 +51,28 @@ export const getRGBContrastRatio = (
|
||||
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Returns a contrasted color (black or white) based on the luminance of another color
|
||||
* Tells whether a color can be measured, which a CSS function that is passed
|
||||
* through unevaluated cannot, and whether it covers what is behind it
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to check
|
||||
* @returns Whether a contrast against this color says anything
|
||||
*/
|
||||
export const isOpaqueColor = (color: string): boolean => {
|
||||
const parsed = parse(color.trim());
|
||||
return parsed !== undefined && (parsed.alpha ?? 1) === 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a contrasted color (black or white) for another color
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
|
||||
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
|
||||
* @returns HEX color, whichever of black and white has the higher contrast ratio
|
||||
*/
|
||||
export const getContrastedColorHex = (color: string): string => {
|
||||
const lum = wcagLuminance(theme2hex(color));
|
||||
return lum > 0.5 ? "#000000" : "#ffffff";
|
||||
const hex = theme2hex(color.trim());
|
||||
// culori throws on a color it cannot read
|
||||
if (!parse(hex)) {
|
||||
return "#ffffff";
|
||||
}
|
||||
return wcagContrast(hex, "#000000") >= wcagContrast(hex, "#ffffff")
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
};
|
||||
|
||||
@@ -33,7 +33,6 @@ export const filterPanelStyles = css`
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
ha-expansion-panel::part(summary) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.content {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { HaSlider } from "../../components/ha-slider";
|
||||
import type { MediaPlayerEntity } from "../../data/media-player";
|
||||
import { formatMediaTime, getCurrentProgress } from "../../data/media-player";
|
||||
|
||||
const PENDING_SEEK_TIMEOUT_MS = 5000;
|
||||
const PENDING_SEEK_TOLERANCE_S = 2;
|
||||
const PROGRESS_INTERVAL_MS = 1000;
|
||||
|
||||
export interface MediaProgressControllerOptions {
|
||||
getStateObj: () => MediaPlayerEntity | undefined;
|
||||
getSlider: () => HaSlider | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a media progress slider: ticks it while the media plays, holds it
|
||||
* on the target after a seek until the player state catches up, and leaves
|
||||
* it alone while the user drags it. The controller owns the slider value;
|
||||
* the host must not bind `.value` and reads `progress` for position text.
|
||||
*/
|
||||
export class MediaProgressController implements ReactiveController {
|
||||
/** Current position in media seconds, pending seek included. */
|
||||
public progress?: number;
|
||||
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private _options: MediaProgressControllerOptions;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
private _pendingPosition?: number;
|
||||
|
||||
private _pendingSince = 0;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
options: MediaProgressControllerOptions
|
||||
) {
|
||||
this._host = host;
|
||||
this._options = options;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostUpdate(): void {
|
||||
this._computeProgress();
|
||||
}
|
||||
|
||||
public hostUpdated(): void {
|
||||
this._writeSlider();
|
||||
this._syncInterval();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._stopInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a seek so the displayed position moves to the target immediately
|
||||
* instead of jumping back until the player state reflects the seek.
|
||||
*/
|
||||
public seek(position: number): void {
|
||||
this._pendingPosition = position;
|
||||
this._pendingSince = Date.now();
|
||||
this._tick();
|
||||
}
|
||||
|
||||
private _tick(): void {
|
||||
this._computeProgress();
|
||||
this._writeSlider();
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
private _syncInterval(): void {
|
||||
const stateObj = this._options.getStateObj();
|
||||
if (
|
||||
stateObj?.state === "playing" &&
|
||||
stateObj.attributes.media_duration &&
|
||||
stateObj.attributes.media_position !== undefined
|
||||
) {
|
||||
if (!this._interval) {
|
||||
this._interval = window.setInterval(
|
||||
() => this._tick(),
|
||||
PROGRESS_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._stopInterval();
|
||||
}
|
||||
}
|
||||
|
||||
private _stopInterval(): void {
|
||||
if (this._interval) {
|
||||
clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeProgress(): void {
|
||||
const stateObj = this._options.getStateObj();
|
||||
if (
|
||||
!stateObj ||
|
||||
!stateObj.attributes.media_duration ||
|
||||
stateObj.attributes.media_position === undefined
|
||||
) {
|
||||
this.progress = undefined;
|
||||
return;
|
||||
}
|
||||
this.progress = this._applyPendingSeek(
|
||||
getCurrentProgress(stateObj),
|
||||
stateObj.state === "playing",
|
||||
stateObj.attributes.media_duration
|
||||
);
|
||||
}
|
||||
|
||||
private _applyPendingSeek(
|
||||
current: number,
|
||||
playing: boolean,
|
||||
duration: number
|
||||
): number {
|
||||
if (this._pendingPosition === undefined) {
|
||||
return current;
|
||||
}
|
||||
const elapsedMs = Date.now() - this._pendingSince;
|
||||
const target = Math.min(
|
||||
this._pendingPosition + (playing ? elapsedMs / 1000 : 0),
|
||||
duration
|
||||
);
|
||||
if (
|
||||
elapsedMs > PENDING_SEEK_TIMEOUT_MS ||
|
||||
Math.abs(current - target) <= PENDING_SEEK_TOLERANCE_S
|
||||
) {
|
||||
this._pendingPosition = undefined;
|
||||
return current;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private _writeSlider(): void {
|
||||
const slider = this._options.getSlider();
|
||||
if (!slider) {
|
||||
return;
|
||||
}
|
||||
slider.valueFormatter = formatMediaTime;
|
||||
if (slider.matches(":state(dragging)")) {
|
||||
return;
|
||||
}
|
||||
slider.value = this.progress ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { timerTimeRemaining } from "../../data/timer";
|
||||
|
||||
/**
|
||||
* Tracks the live remaining time of a timer entity. While the timer is
|
||||
* active, the host is re-rendered every second with an updated
|
||||
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
|
||||
*
|
||||
* The host must call `setStateObj` whenever its timer entity changes.
|
||||
*/
|
||||
export class TimerRemainingTimeController implements ReactiveController {
|
||||
public timeRemaining?: number;
|
||||
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public setStateObj(stateObj: HassEntity | undefined): void {
|
||||
this._stateObj = stateObj;
|
||||
this._startInterval();
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._startInterval();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._clearInterval();
|
||||
}
|
||||
|
||||
private _startInterval(): void {
|
||||
this._clearInterval();
|
||||
if (!this._stateObj) {
|
||||
this.timeRemaining = undefined;
|
||||
return;
|
||||
}
|
||||
this._calculateRemaining();
|
||||
|
||||
if (this._stateObj.state === "active") {
|
||||
this._interval = window.setInterval(() => {
|
||||
this._calculateRemaining();
|
||||
this._host.requestUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearInterval(): void {
|
||||
if (this._interval) {
|
||||
clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateRemaining(): void {
|
||||
this.timeRemaining = this._stateObj
|
||||
? timerTimeRemaining(this._stateObj)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
@@ -145,16 +145,11 @@ export const applyThemesOnElement = (
|
||||
element.__themes = { cacheKey, keys: newTheme?.keys };
|
||||
|
||||
// Set and/or reset styles
|
||||
if (window.ShadyCSS) {
|
||||
// Use ShadyCSS if available
|
||||
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
|
||||
} else {
|
||||
for (const s in styles) {
|
||||
if (s === null) {
|
||||
element.style.removeProperty(s);
|
||||
} else {
|
||||
element.style.setProperty(s, styles[s]);
|
||||
}
|
||||
for (const s in styles) {
|
||||
if (s === null) {
|
||||
element.style.removeProperty(s);
|
||||
} else {
|
||||
element.style.setProperty(s, styles[s]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Toggle Attribute Polyfill because it's too new for some browsers
|
||||
export const toggleAttribute = (
|
||||
el: HTMLElement,
|
||||
name: string,
|
||||
force?: boolean
|
||||
) => {
|
||||
if (force !== undefined) {
|
||||
force = !!force;
|
||||
}
|
||||
|
||||
if (el.hasAttribute(name)) {
|
||||
if (force) {
|
||||
return true;
|
||||
}
|
||||
|
||||
el.removeAttribute(name);
|
||||
return false;
|
||||
}
|
||||
if (force === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
el.setAttribute(name, "");
|
||||
return true;
|
||||
};
|
||||
@@ -29,7 +29,7 @@ export const computeEntityEntryName = (
|
||||
fallbackStateObj?: HassEntity
|
||||
): string | undefined => {
|
||||
const name =
|
||||
entry.name ||
|
||||
entry.name ??
|
||||
("original_name" in entry && entry.original_name != null
|
||||
? String(entry.original_name)
|
||||
: undefined);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const webComponentsSupported = "attachShadow" in Element.prototype;
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* ES5-compatible implementation of the keyed directive.
|
||||
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
|
||||
*
|
||||
* This implementation avoids parameter destructuring in the update() method,
|
||||
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
|
||||
*
|
||||
* Used only for ES5 builds (legacy browsers). Modern builds use the original
|
||||
* lit-html keyed directive.
|
||||
*
|
||||
* @see https://github.com/home-assistant/frontend/issues/28732
|
||||
*/
|
||||
import { directive, Directive } from "lit-html/directive.js";
|
||||
import { setCommittedValue } from "lit-html/directive-helpers.js";
|
||||
// eslint-disable-next-line lit/no-legacy-imports
|
||||
import { nothing } from "lit-html";
|
||||
import type { Part } from "lit-html/directive.js";
|
||||
|
||||
class KeyedES5 extends Directive {
|
||||
private _key: unknown = nothing;
|
||||
|
||||
render(k: unknown, v: unknown) {
|
||||
this._key = k;
|
||||
return v;
|
||||
}
|
||||
|
||||
update(part: unknown, args: [unknown, unknown]) {
|
||||
const k = args[0];
|
||||
const v = args[1];
|
||||
if (k !== this._key) {
|
||||
// Clear the part before returning a value. The one-arg form of
|
||||
// setCommittedValue sets the value to a sentinel which forces a
|
||||
// commit the next render.
|
||||
setCommittedValue(part as Part);
|
||||
this._key = k;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a renderable value with a unique key. When the key changes, the
|
||||
* previous DOM is removed and disposed before rendering the next value, even
|
||||
* if the value - such as a template - is the same.
|
||||
*
|
||||
* This is useful for forcing re-renders of stateful components, or working
|
||||
* with code that expects new data to generate new HTML elements, such as some
|
||||
* animation techniques.
|
||||
*/
|
||||
export const keyed = directive(KeyedES5);
|
||||
+191
-2
@@ -42,6 +42,7 @@ export const updateHistoryState = (patch: Record<string, unknown>) => {
|
||||
*/
|
||||
export const replaceCurrentUrl = (url: string) => {
|
||||
mainWindow.history.replaceState(mainWindow.history.state, "", url);
|
||||
rememberCurrentEntry();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,6 +81,105 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a page with unsaved changes (e.g. the automation editor) veto
|
||||
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
|
||||
* true when navigation may proceed.
|
||||
*/
|
||||
export interface UnsavedChangesGuard {
|
||||
isDirty(): boolean;
|
||||
prompt(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
|
||||
|
||||
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.add(guard);
|
||||
};
|
||||
|
||||
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.delete(guard);
|
||||
};
|
||||
|
||||
const dirtyGuards = (): UnsavedChangesGuard[] =>
|
||||
[...unsavedChangesGuards].filter((guard) => guard.isDirty());
|
||||
|
||||
let pendingUnsavedPrompt: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* Counts navigations that changed the history entry, so a navigation held up
|
||||
* by an unsaved-changes prompt can tell whether a newer one has moved the app
|
||||
* on in the meantime.
|
||||
*/
|
||||
let committedNavigations = 0;
|
||||
|
||||
interface HistoryEntry {
|
||||
path: string;
|
||||
/** Path of the entry behind this one, stamped by `performNavigation`. */
|
||||
from?: string;
|
||||
}
|
||||
|
||||
const readEntry = (): HistoryEntry => ({
|
||||
path: currentPath(),
|
||||
from: mainWindow.history.state?.from,
|
||||
});
|
||||
|
||||
/**
|
||||
* How far a pop moved from `entry`, signed, or undefined when it cannot be told:
|
||||
* the entry behind us is the one our `from` names, the one ahead is the one
|
||||
* whose `from` names us. A stack with the same path on both sides matches both,
|
||||
* and back is then by far the likelier press.
|
||||
*/
|
||||
const popStep = (entry: HistoryEntry): number | undefined => {
|
||||
if (currentPath() === entry.from) {
|
||||
return -1;
|
||||
}
|
||||
return mainWindow.history.state?.from === entry.path ? 1 : undefined;
|
||||
};
|
||||
|
||||
let currentEntry: HistoryEntry = readEntry();
|
||||
|
||||
const rememberCurrentEntry = () => {
|
||||
currentEntry = readEntry();
|
||||
};
|
||||
|
||||
/** Where the pops held while a prompt is open left us. */
|
||||
let heldEntry: HistoryEntry | undefined;
|
||||
let heldSteps = 0;
|
||||
|
||||
/** The entry `goBack()` asked to leave; the pop it triggers is not prompted. */
|
||||
let popRequestedFromPath: string | undefined;
|
||||
|
||||
/**
|
||||
* Asks each dirty guard whether navigation may proceed. Returns true when
|
||||
* nothing is dirty or every prompt was confirmed. Concurrent navigations
|
||||
* share one pending prompt instead of stacking dialogs; the dirty check runs
|
||||
* before joining it, so a navigation triggered from inside a prompt (e.g. by
|
||||
* its save action) cannot deadlock on its own promise.
|
||||
*/
|
||||
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
|
||||
const guards = dirtyGuards();
|
||||
if (!guards.length) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (!pendingUnsavedPrompt) {
|
||||
pendingUnsavedPrompt = (async () => {
|
||||
try {
|
||||
for (const guard of guards) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await guard.prompt())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
pendingUnsavedPrompt = undefined;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return pendingUnsavedPrompt;
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
@@ -91,7 +191,7 @@ const buildHistoryState = (
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
@@ -124,12 +224,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
);
|
||||
}
|
||||
|
||||
rememberCurrentEntry();
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
committedNavigations += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
// Only guard actual departures: navigating to the current path keeps the
|
||||
// page, and any unsaved state on it, mounted.
|
||||
if (path !== currentPath()) {
|
||||
const navigationsBeforePrompt = committedNavigations;
|
||||
if (!(await ensureUnsavedChangesConfirmed())) {
|
||||
return false;
|
||||
}
|
||||
if (committedNavigations !== navigationsBeforePrompt) {
|
||||
// Another navigation landed while the prompt was waiting for an answer,
|
||||
// so this destination is stale. Dropping it keeps a late answer from
|
||||
// pulling the user back off the page they are on now.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return performNavigation(path, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
@@ -142,6 +262,9 @@ export const canGoBack = (): boolean =>
|
||||
/**
|
||||
* Navigate back to the page we came from, falling back to a path when the
|
||||
* previous entry is not ours (deep link, login redirect, fresh tab).
|
||||
* Deliberately not guarded against unsaved changes: pages with such a guard
|
||||
* confirm in their own back handlers, and delete flows leave through here
|
||||
* after the edited item is already gone.
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -152,9 +275,75 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
// Read after closing dialogs: their history entries are popped by then, so
|
||||
// this is the state of the page entry.
|
||||
if (canGoBack()) {
|
||||
popRequestedFromPath = currentEntry.path;
|
||||
mainWindow.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
await performNavigation(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a history pop before the router acts on it. A pop cannot be cancelled,
|
||||
* so one away from a page with unsaved changes holds the route and prompts;
|
||||
* `resume` runs once the user agrees to leave. The prompt must add no history
|
||||
* entry of its own, or the entries the pop left would be truncated.
|
||||
*/
|
||||
export const handleHistoryPop = (resume: () => void): void => {
|
||||
if (heldEntry) {
|
||||
const heldStep = popStep(heldEntry);
|
||||
if (heldStep !== undefined) {
|
||||
heldSteps += heldStep;
|
||||
heldEntry = readEntry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPath() === currentEntry.path) {
|
||||
// A dialog's history entry was popped, not a page.
|
||||
rememberCurrentEntry();
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
|
||||
const step = popStep(currentEntry);
|
||||
if (
|
||||
popRequestedFromPath === currentEntry.path ||
|
||||
// Not a pop we could undo, so do not hold it.
|
||||
step === undefined ||
|
||||
!dirtyGuards().length
|
||||
) {
|
||||
popRequestedFromPath = undefined;
|
||||
rememberCurrentEntry();
|
||||
committedNavigations += 1;
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
|
||||
heldSteps = step;
|
||||
heldEntry = readEntry();
|
||||
const navigationsAtPop = committedNavigations;
|
||||
ensureUnsavedChangesConfirmed().then(
|
||||
(confirmed) => {
|
||||
const steps = heldSteps;
|
||||
heldEntry = undefined;
|
||||
if (committedNavigations !== navigationsAtPop) {
|
||||
// A navigation landed while the prompt was open.
|
||||
return;
|
||||
}
|
||||
if (!confirmed) {
|
||||
// go(0) would reload the document, and at zero we are already back.
|
||||
if (steps) {
|
||||
mainWindow.history.go(-steps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rememberCurrentEntry();
|
||||
committedNavigations += 1;
|
||||
resume();
|
||||
},
|
||||
() => {
|
||||
heldEntry = undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export const startMediaProgressInterval = (
|
||||
interval: number | undefined,
|
||||
callback: () => void,
|
||||
intervalMs = 1000
|
||||
): number => {
|
||||
if (interval) {
|
||||
return interval;
|
||||
}
|
||||
return window.setInterval(callback, intervalMs);
|
||||
};
|
||||
|
||||
export const stopMediaProgressInterval = (
|
||||
interval: number | undefined
|
||||
): number | undefined => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import deepFreeze from "deep-freeze";
|
||||
|
||||
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
|
||||
|
||||
export const shareInFlightRequest = <T>(
|
||||
owner: object,
|
||||
key: string,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
let requests = inFlightRequests.get(owner);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
inFlightRequests.set(owner, requests);
|
||||
}
|
||||
|
||||
const ownerRequests = requests;
|
||||
const existing = ownerRequests.get(key);
|
||||
if (existing) {
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = fetcher()
|
||||
.then((result) => deepFreeze(result) as T)
|
||||
.finally(() => {
|
||||
if (ownerRequests.get(key) !== request) {
|
||||
return;
|
||||
}
|
||||
|
||||
ownerRequests.delete(key);
|
||||
if (ownerRequests.size === 0) {
|
||||
inFlightRequests.delete(owner);
|
||||
}
|
||||
});
|
||||
|
||||
ownerRequests.set(key, request);
|
||||
return request;
|
||||
};
|
||||
@@ -328,6 +328,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
onFractionDigits: (digits) => {
|
||||
if (digits !== this._yAxisFractionDigits) {
|
||||
this._yAxisFractionDigits = digits;
|
||||
|
||||
@@ -446,6 +446,7 @@ export class StatisticsChart extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
// Bar charts stay anchored at 0, so precision must reflect the
|
||||
// 0-based range that is actually rendered.
|
||||
includeZero: !yAxisScale,
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
|
||||
import { getPrecision, nice, round } from "echarts/lib/util/number";
|
||||
|
||||
// A range smaller than this fraction of the axis magnitude is floating-point
|
||||
// noise (e.g. from summed statistics), not real precision.
|
||||
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
|
||||
|
||||
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
|
||||
// precision derived here cannot drift from the ticks it renders.
|
||||
const SPLIT_NUMBER = 5;
|
||||
|
||||
// How thin a gap between the data and the plot edge counts as no gap at all,
|
||||
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
|
||||
// maximum to a tick multiple, which usually leaves headroom, but quantized
|
||||
// states often land exactly on a tick and get none — collapsing area-filled
|
||||
// series, which are drawn from their value down to the axis minimum. Widening
|
||||
// the extent by this much before that rounding bumps any axis with less
|
||||
// headroom out to a full tick, and leaves the rest where they are.
|
||||
const GAP_FRACTION_OF_SPAN = 0.02;
|
||||
|
||||
// A percentage has a real ceiling the way zero is a real floor, so the gap must
|
||||
// not push the axis past it. Not every `%` sensor is bounded — power factor is
|
||||
// signed and can read over 100 — so this only applies while the data stays under.
|
||||
const PERCENT_MAX = 100;
|
||||
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
// observed data range, by asking ECharts for the same tick interval it will
|
||||
// render. This matches the precision it actually draws, so labels are neither
|
||||
// truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -22,13 +41,7 @@ export function computeYAxisFractionDigits(
|
||||
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
|
||||
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
|
||||
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
// Rounding the mantissa to a nice value only ever carries to the next power
|
||||
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
|
||||
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
|
||||
return Math.max(0, -niceExponent);
|
||||
return getPrecision(nice(range / SPLIT_NUMBER, true));
|
||||
}
|
||||
|
||||
interface YAxisExtentValues {
|
||||
@@ -44,34 +57,116 @@ const resolveYAxisBound = (
|
||||
values: YAxisExtentValues
|
||||
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
|
||||
|
||||
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
|
||||
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
|
||||
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
|
||||
// always before the label formatter runs, so recomputing the fraction digits
|
||||
// here keeps zoomed-in labels distinct. The callbacks return the original
|
||||
// bounds unchanged, so auto-scaling still applies when a bound is not set.
|
||||
// A constant series has no span for the gap to be a fraction of, so ECharts
|
||||
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
|
||||
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
|
||||
// expansion a flat series relies on for its window. Run that expansion here and
|
||||
// return fixed bounds, which also suppresses the gap.
|
||||
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
|
||||
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
|
||||
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
|
||||
const precision = getPrecision(interval);
|
||||
return {
|
||||
min: round(Math.floor(lo / interval) * interval, precision),
|
||||
max: round(Math.ceil(hi / interval) * interval, precision),
|
||||
};
|
||||
};
|
||||
|
||||
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
|
||||
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
|
||||
// callbacks with the extent of the visible (zoom-filtered) data on every
|
||||
// dataZoom, and always before the label formatter runs, so the fraction digits
|
||||
// recomputed here track the zoomed range. A callback returns `undefined`
|
||||
// wherever auto-scaling should stand, and a number only where the axis has to be
|
||||
// pinned: an explicit bound, the zero anchor, or a constant series.
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
|
||||
// Such an axis also gets no gap: pushing it below zero would defeat the zero
|
||||
// anchoring and leave the bars floating above the axis.
|
||||
includeZero?: boolean;
|
||||
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
|
||||
// a limit the data itself never crosses.
|
||||
unit?: string;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
min: (values: YAxisExtentValues) => number | undefined;
|
||||
max: (values: YAxisExtentValues) => number | undefined;
|
||||
boundaryGap: [number, number];
|
||||
splitNumber: number;
|
||||
} {
|
||||
const { min, max, includeZero, onFractionDigits } = options;
|
||||
const { min, max, includeZero, unit, onFractionDigits } = options;
|
||||
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
|
||||
|
||||
const resolveBounds = (values: YAxisExtentValues) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
if (
|
||||
includeZero ||
|
||||
!Number.isFinite(values.min) ||
|
||||
!Number.isFinite(values.max)
|
||||
) {
|
||||
return { min: resolvedMin, max: resolvedMax, gap: 0 };
|
||||
}
|
||||
if (values.min === values.max) {
|
||||
const flat = flatSeriesExtent(values.min, [
|
||||
resolvedMin !== undefined,
|
||||
resolvedMax !== undefined,
|
||||
]);
|
||||
// The expansion is a fraction of the magnitude, so a constant series near
|
||||
// the ceiling would otherwise overshoot it too.
|
||||
const flatMax =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? Math.min(flat.max, naturalMax)
|
||||
: flat.max;
|
||||
return {
|
||||
min: resolvedMin ?? flat.min,
|
||||
max: resolvedMax ?? flatMax,
|
||||
gap: 0,
|
||||
};
|
||||
}
|
||||
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
|
||||
// Never let the gap carry a series past a boundary it does not itself cross.
|
||||
const floor = values.min >= 0 ? 0 : undefined;
|
||||
const ceiling =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? naturalMax
|
||||
: values.max <= 0
|
||||
? 0
|
||||
: undefined;
|
||||
return {
|
||||
min:
|
||||
resolvedMin ??
|
||||
(floor !== undefined && values.min - floor < gap ? floor : undefined),
|
||||
max:
|
||||
resolvedMax ??
|
||||
(ceiling !== undefined && ceiling - values.max < gap
|
||||
? ceiling
|
||||
: undefined),
|
||||
gap,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// Always emit the key. `setOption` merges the Y axis rather than replacing
|
||||
// it, so a conditionally spread gap would survive a chart switching to a
|
||||
// zero-anchored type and leave its bars floating.
|
||||
boundaryGap: includeZero
|
||||
? [0, 0]
|
||||
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
|
||||
splitNumber: SPLIT_NUMBER,
|
||||
min: (values) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
const bounds = resolveBounds(values);
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
computeYAxisFractionDigits(
|
||||
bounds.min ?? values.min - bounds.gap,
|
||||
bounds.max ?? values.max + bounds.gap,
|
||||
includeZero
|
||||
)
|
||||
);
|
||||
return resolvedMin;
|
||||
return bounds.min;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
max: (values) => resolveBounds(values).max,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,45 +204,17 @@ export class HaDataTable extends LitElement {
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public selectAll(): void {
|
||||
public selectAll(extraFilter?: (row: DataTableRowData) => boolean): void {
|
||||
this._checkedRows = (this._filteredData || [])
|
||||
.filter((data) => data.selectable !== false)
|
||||
.filter(
|
||||
(data) =>
|
||||
data.selectable !== false && (!extraFilter || extraFilter(data))
|
||||
)
|
||||
.map((data) => data[this.id]);
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public select(ids: string[], clear?: boolean): void {
|
||||
if (clear) {
|
||||
this._checkedRows = [];
|
||||
}
|
||||
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
|
||||
const rowLookup = new Map(
|
||||
(this._filteredData || []).map((data) => [data[this.id], data])
|
||||
);
|
||||
const checkedRows = new Set(this._checkedRows);
|
||||
ids.forEach((id) => {
|
||||
const row = rowLookup.get(id);
|
||||
if (row?.selectable !== false && !checkedRows.has(id)) {
|
||||
this._checkedRows.push(id);
|
||||
checkedRows.add(id);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public unselect(ids: string[]): void {
|
||||
ids.forEach((id) => {
|
||||
const index = this._checkedRows.indexOf(id);
|
||||
if (index > -1) {
|
||||
this._checkedRows.splice(index, 1);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._filteredData?.length) {
|
||||
@@ -1235,10 +1207,8 @@ export class HaDataTable extends LitElement {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.mdc-data-table__header-row {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mdc-data-table__cell,
|
||||
|
||||
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
getStatisticIds,
|
||||
getStatisticLabel,
|
||||
type StatisticsMetaData,
|
||||
} from "../../data/recorder";
|
||||
import { getStatisticIds } from "../../data/recorder_statistic_ids";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import "../ha-combo-box-item";
|
||||
|
||||
@@ -293,12 +293,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
|
||||
);
|
||||
overflow: hidden;
|
||||
color: var(--primary-text-color);
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-surface-backdrop-filter,
|
||||
none
|
||||
);
|
||||
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
|
||||
static styles = css`
|
||||
.issue-type {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
padding: var(--ha-alert-padding, 8px);
|
||||
display: flex;
|
||||
}
|
||||
.icon {
|
||||
|
||||
@@ -1061,31 +1061,18 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
-webkit-animation: sk-bounce 2s infinite ease-in-out;
|
||||
animation: sk-bounce 2s infinite ease-in-out;
|
||||
}
|
||||
.double-bounce2 {
|
||||
-webkit-animation-delay: -1s;
|
||||
animation-delay: -1s;
|
||||
}
|
||||
@-webkit-keyframes sk-bounce {
|
||||
0%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes sk-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ export class HaBadge extends LitElement {
|
||||
--ha-card-background,
|
||||
var(--card-background-color, white)
|
||||
);
|
||||
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
border-width: var(--ha-card-border-width, 1px);
|
||||
box-shadow: var(--ha-card-box-shadow, none);
|
||||
|
||||
@@ -432,13 +432,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
wa-drawer::part(dialog)::backdrop {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
@@ -470,10 +463,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
var(--card-background-color, var(--ha-color-surface-default))
|
||||
)
|
||||
);
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-bottom-sheet-surface-backdrop-filter,
|
||||
var(--ha-dialog-surface-backdrop-filter, none)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-surface-backdrop-filter,
|
||||
var(--ha-dialog-surface-backdrop-filter, none)
|
||||
|
||||
@@ -13,7 +13,6 @@ export class HaCard extends LitElement {
|
||||
--ha-card-background,
|
||||
var(--card-background-color, white)
|
||||
);
|
||||
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
box-shadow: var(--ha-card-box-shadow, none);
|
||||
box-sizing: border-box;
|
||||
|
||||
+6
-6
@@ -1,12 +1,12 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import "../../../components/list/ha-list-nav";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-icon-next";
|
||||
import "./ha-svg-icon";
|
||||
import "./item/ha-list-item-button";
|
||||
import "./list/ha-list-nav";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@customElement("ha-config-navigation-list")
|
||||
class HaConfigNavigationList extends LitElement {
|
||||
@@ -56,7 +56,7 @@ export class HaControlSelect extends LitElement {
|
||||
this.updateComplete.then(() => {
|
||||
// eslint-disable-next-line lit/prefer-query-decorators
|
||||
const option = this.shadowRoot?.querySelector(
|
||||
`#option-${this.options![index].value}`
|
||||
`[data-index="${index}"]`
|
||||
) as HTMLElement;
|
||||
option?.focus();
|
||||
});
|
||||
@@ -144,7 +144,11 @@ export class HaControlSelect extends LitElement {
|
||||
this.options,
|
||||
(option) => option.value,
|
||||
(option, index) =>
|
||||
this._renderOption(option, index === this._tabbableIndex)
|
||||
this._renderOption(
|
||||
option,
|
||||
index,
|
||||
index === this._tabbableIndex
|
||||
)
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
@@ -159,12 +163,17 @@ export class HaControlSelect extends LitElement {
|
||||
return selectedIndex === -1 ? 0 : selectedIndex;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
|
||||
private _renderOption(
|
||||
option: ControlSelectOption,
|
||||
index: number,
|
||||
tabbable: boolean
|
||||
) {
|
||||
const isSelected = this.value === option.value;
|
||||
|
||||
return html`
|
||||
<div
|
||||
id=${`option-${option.value}`}
|
||||
data-index=${index}
|
||||
class=${classMap({
|
||||
option: true,
|
||||
selected: isSelected,
|
||||
@@ -323,8 +332,6 @@ export class HaControlSelect extends LitElement {
|
||||
.option .content span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
-webkit-hyphens: auto;
|
||||
-moz-hyphens: auto;
|
||||
hyphens: auto;
|
||||
}
|
||||
:host([vertical]) {
|
||||
|
||||
@@ -393,10 +393,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-surface-backdrop-filter,
|
||||
none
|
||||
);
|
||||
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
|
||||
box-shadow: var(--dialog-box-shadow, var(--wa-shadow-l));
|
||||
color: var(--primary-text-color);
|
||||
@@ -429,10 +425,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog)::backdrop {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
|
||||
@@ -95,6 +95,10 @@ export class HaDrawer extends LitElement {
|
||||
}
|
||||
|
||||
private _handleAfterHide(ev: Event) {
|
||||
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this.open = false;
|
||||
fireEvent(this, "hass-drawer-closed");
|
||||
|
||||
@@ -63,11 +63,6 @@ class HaFaded extends LitElement {
|
||||
}
|
||||
.faded {
|
||||
cursor: pointer;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 25%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(to bottom, black 25%, transparent 100%);
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Selector } from "../../data/selector";
|
||||
import {
|
||||
getSelectorInitialValue,
|
||||
getSelectorInitialValueOrUndefined,
|
||||
} from "./get-selector-initial-value";
|
||||
import type { HaFormData, HaFormSchema } from "./types";
|
||||
|
||||
interface ComputeInitialHaFormDataOptions {
|
||||
skipUnsupportedSelectors?: boolean;
|
||||
}
|
||||
|
||||
const setDefaultValue = (
|
||||
field: HaFormSchema,
|
||||
value: HaFormData | undefined
|
||||
@@ -18,7 +25,8 @@ const setDefaultValue = (
|
||||
};
|
||||
|
||||
export const computeInitialHaFormData = (
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[]
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[],
|
||||
options?: ComputeInitialHaFormDataOptions
|
||||
): Record<string, any> => {
|
||||
const data = {};
|
||||
schema.forEach((field) => {
|
||||
@@ -33,7 +41,7 @@ export const computeInitialHaFormData = (
|
||||
} else if ("default" in field) {
|
||||
data[field.name] = setDefaultValue(field, field.default);
|
||||
} else if (field.type === "expandable") {
|
||||
const expandableData = computeInitialHaFormData(field.schema);
|
||||
const expandableData = computeInitialHaFormData(field.schema, options);
|
||||
if (field.required || Object.keys(expandableData).length) {
|
||||
// Only add expandable data if it's required or any of its children have initial values.
|
||||
data[field.name] = expandableData;
|
||||
@@ -62,104 +70,11 @@ export const computeInitialHaFormData = (
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("selector" in field) {
|
||||
const selector: Selector = field.selector;
|
||||
|
||||
if ("device" in selector) {
|
||||
data[field.name] = selector.device?.multiple ? [] : "";
|
||||
} else if ("entity" in selector) {
|
||||
data[field.name] = selector.entity?.multiple ? [] : "";
|
||||
} else if ("area" in selector) {
|
||||
data[field.name] = selector.area?.multiple ? [] : "";
|
||||
} else if ("label" in selector) {
|
||||
data[field.name] = selector.label?.multiple ? [] : "";
|
||||
} else if ("boolean" in selector) {
|
||||
data[field.name] = false;
|
||||
} else if (
|
||||
"addon" in selector ||
|
||||
"attribute" in selector ||
|
||||
"file" in selector ||
|
||||
"icon" in selector ||
|
||||
"serial_port" in selector ||
|
||||
"template" in selector ||
|
||||
"text" in selector ||
|
||||
"theme" in selector ||
|
||||
"object" in selector
|
||||
) {
|
||||
data[field.name] = "";
|
||||
} else if ("number" in selector) {
|
||||
data[field.name] = selector.number?.min ?? 0;
|
||||
} else if ("select" in selector) {
|
||||
if (selector.select?.options.length) {
|
||||
const firstOption = selector.select.options[0];
|
||||
const val =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
data[field.name] = selector.select.multiple ? [val] : val;
|
||||
}
|
||||
} else if ("country" in selector) {
|
||||
if (selector.country?.countries?.length) {
|
||||
data[field.name] = selector.country.countries[0];
|
||||
}
|
||||
} else if ("language" in selector) {
|
||||
if (selector.language?.languages?.length) {
|
||||
data[field.name] = selector.language.languages[0];
|
||||
}
|
||||
} else if ("duration" in selector) {
|
||||
data[field.name] = {
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("time" in selector) {
|
||||
data[field.name] = "00:00:00";
|
||||
} else if ("date" in selector || "datetime" in selector) {
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
data[field.name] = `${now}T00:00:00`;
|
||||
} else if ("color_rgb" in selector) {
|
||||
data[field.name] = [0, 0, 0];
|
||||
} else if ("color_temp" in selector) {
|
||||
data[field.name] = selector.color_temp?.min_mireds ?? 153;
|
||||
} else if (
|
||||
"action" in selector ||
|
||||
"trigger" in selector ||
|
||||
"condition" in selector
|
||||
) {
|
||||
data[field.name] = [];
|
||||
} else if ("media" in selector || "target" in selector) {
|
||||
data[field.name] = {};
|
||||
} else if ("state" in selector) {
|
||||
data[field.name] = selector.state?.multiple ? [] : "";
|
||||
} else if ("choose" in selector) {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
if (!firstChoice) {
|
||||
data[field.name] = {};
|
||||
} else {
|
||||
data[field.name] = {
|
||||
active_choice: firstChoice,
|
||||
[firstChoice]: computeInitialHaFormData([
|
||||
{
|
||||
name: firstChoice,
|
||||
selector: selector.choose.choices[firstChoice].selector,
|
||||
},
|
||||
])[firstChoice],
|
||||
};
|
||||
}
|
||||
} else if ("numeric_threshold" in selector) {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
data[field.name] =
|
||||
type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
|
||||
);
|
||||
const initialValue = options?.skipUnsupportedSelectors
|
||||
? getSelectorInitialValueOrUndefined(field.selector)
|
||||
: getSelectorInitialValue(field.selector);
|
||||
if (initialValue !== undefined) {
|
||||
data[field.name] = initialValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,25 +1,90 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type { Selector } from "../../data/selector";
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorFallbackValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_FALLBACK_VALUES = {
|
||||
action: undefined,
|
||||
addon: undefined,
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: undefined,
|
||||
areas_display: undefined,
|
||||
attribute: undefined,
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: undefined,
|
||||
color_rgb: undefined,
|
||||
condition: undefined,
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: undefined,
|
||||
date: undefined,
|
||||
datetime: undefined,
|
||||
device: undefined,
|
||||
device_class: undefined,
|
||||
duration: undefined,
|
||||
entity: undefined,
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: undefined,
|
||||
floor: undefined,
|
||||
label: undefined,
|
||||
language: undefined,
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: undefined,
|
||||
object: undefined,
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: undefined,
|
||||
selector: undefined,
|
||||
serial_port: undefined,
|
||||
state: undefined,
|
||||
state_class: undefined,
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: undefined,
|
||||
template: undefined,
|
||||
text: undefined,
|
||||
time: undefined,
|
||||
icon: undefined,
|
||||
media: undefined,
|
||||
theme: undefined,
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: undefined,
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
},
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorFallbackValues;
|
||||
|
||||
/**
|
||||
* Value a selector already displays when no field value is set.
|
||||
* Used when enabling an optional service/trigger/condition field.
|
||||
*/
|
||||
export const getSelectorFallbackValue = (selector: Selector): unknown => {
|
||||
if ("constant" in selector) {
|
||||
return selector.constant?.value;
|
||||
}
|
||||
if ("boolean" in selector) {
|
||||
return false;
|
||||
}
|
||||
if ("number" in selector) {
|
||||
return selector.number?.min ?? 0;
|
||||
}
|
||||
if ("color_temp" in selector) {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
}
|
||||
return undefined;
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
|
||||
|
||||
return fallbackValue?.(selector as never);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorInitialValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_INITIAL_VALUES = {
|
||||
action: () => [],
|
||||
addon: () => "",
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: (selector) => (selector.area?.multiple ? [] : ""),
|
||||
areas_display: undefined,
|
||||
attribute: () => "",
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: (selector) => {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
|
||||
if (!firstChoice) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const childValue = getSelectorInitialValueOrUndefined(
|
||||
selector.choose.choices[firstChoice].selector
|
||||
);
|
||||
|
||||
return childValue === undefined
|
||||
? { active_choice: firstChoice }
|
||||
: { active_choice: firstChoice, [firstChoice]: childValue };
|
||||
},
|
||||
color_rgb: () => [0, 0, 0],
|
||||
condition: () => [],
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: (selector) => selector.country?.countries?.[0],
|
||||
date: () => new Date().toISOString().slice(0, 10),
|
||||
datetime: () => `${new Date().toISOString().slice(0, 10)} 00:00:00`,
|
||||
device: (selector) => (selector.device?.multiple ? [] : ""),
|
||||
device_class: (selector) =>
|
||||
selector.device_class?.multiple ? [] : undefined,
|
||||
duration: () => ({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
}),
|
||||
entity: (selector) => (selector.entity?.multiple ? [] : ""),
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: () => "",
|
||||
floor: undefined,
|
||||
label: (selector) => (selector.label?.multiple ? [] : ""),
|
||||
language: (selector) => selector.language?.languages?.[0],
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: (selector) => {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
|
||||
return type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
},
|
||||
object: (selector) => (selector.object?.multiple ? [] : ""),
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: (selector) => {
|
||||
const select = selector.select;
|
||||
|
||||
if (!select?.options.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstOption = select.options[0];
|
||||
const value =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
|
||||
return select.multiple ? [value] : value;
|
||||
},
|
||||
selector: undefined,
|
||||
serial_port: () => "",
|
||||
state: (selector) => (selector.state?.multiple ? [] : ""),
|
||||
state_class: (selector) => (selector.state_class?.multiple ? [] : undefined),
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: () => ({}),
|
||||
template: () => "",
|
||||
text: (selector) => (selector.text?.multiple ? [] : ""),
|
||||
time: () => "00:00:00",
|
||||
icon: () => "",
|
||||
media: (selector) => (selector.media?.multiple ? [] : {}),
|
||||
theme: () => "",
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: () => [],
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
},
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorInitialValues;
|
||||
|
||||
export const getSelectorInitialValueOrUndefined = (
|
||||
selector: Selector
|
||||
): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
|
||||
};
|
||||
|
||||
export const getSelectorInitialValue = (selector: Selector): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const initialValue = SELECTOR_INITIAL_VALUES[type];
|
||||
|
||||
if (!initialValue) {
|
||||
throw new Error(`Selector ${type} not supported in initial form data`);
|
||||
}
|
||||
|
||||
return initialValue(selector as never);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiPlaylistPlus } from "@mdi/js";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext } from "../data/context";
|
||||
@@ -120,6 +122,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
|
||||
@query("wa-popover") private _popover?: WaPopover;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig?: ContextType<typeof configContext>;
|
||||
@@ -130,6 +134,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@state() private _popoverWidth = 0;
|
||||
|
||||
@state() private _popoverMinHeight = 0;
|
||||
|
||||
@state() private _openedNarrow = false;
|
||||
|
||||
@state() private _unknownValue = false;
|
||||
@@ -240,9 +246,12 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
: html`
|
||||
<wa-popover
|
||||
.open=${this._pickerWrapperOpen}
|
||||
style="--body-width: ${this._popoverWidth}px;"
|
||||
style=${styleMap({
|
||||
"--body-width": `${this._popoverWidth}px`,
|
||||
"--body-min-height": `${this._popoverMinHeight}px`,
|
||||
})}
|
||||
without-arrow
|
||||
distance="-4"
|
||||
distance="0"
|
||||
.placement=${this.popoverPlacement}
|
||||
.for=${this.popoverAnchor ? null : "picker"}
|
||||
.anchor=${this.popoverAnchor ?? null}
|
||||
@@ -265,12 +274,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderComboBox(dialogMode = false) {
|
||||
if (!this._opened) {
|
||||
// The list sizes the popover, so it is rendered as it opens, not once shown.
|
||||
if (!this._pickerWrapperOpen && !this._opened) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-picker-combo-box
|
||||
id="combo-box"
|
||||
.shown=${this._opened}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.label=${this.searchLabel}
|
||||
.value=${this._selectedValue ?? this.value}
|
||||
@@ -339,6 +350,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
private _dialogOpened = () => {
|
||||
this._opened = true;
|
||||
// Filtering must not shrink the popover under the size it opened with.
|
||||
this._popoverMinHeight = this._popover?.body.offsetHeight ?? 0;
|
||||
fireEvent(this, "picker-opened");
|
||||
requestAnimationFrame(() => {
|
||||
// Set initial field value if needed
|
||||
@@ -372,6 +385,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
this._opened = false;
|
||||
this._pickerWrapperOpen = false;
|
||||
this._popoverMinHeight = 0;
|
||||
this._selectedValue = undefined;
|
||||
this._unsubscribeTinyKeys?.();
|
||||
fireEvent(this, "picker-closed");
|
||||
@@ -474,6 +488,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
/* The surface of a dropdown menu, not of a dialog. */
|
||||
--wa-panel-border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
@@ -486,14 +502,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
--ha-generic-picker-max-width,
|
||||
var(--ha-generic-picker-width, max(var(--body-width), 250px))
|
||||
);
|
||||
max-height: 500px;
|
||||
height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--ha-box-shadow-m);
|
||||
height: fit-content;
|
||||
min-height: var(--body-min-height, 0);
|
||||
max-height: min(70vh, 500px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-height: 1000px) {
|
||||
wa-popover::part(body) {
|
||||
max-height: 400px;
|
||||
max-height: min(70vh, 400px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export class HaHeaderBar extends LitElement {
|
||||
--app-header-background-color,
|
||||
var(--primary-background-color)
|
||||
);
|
||||
backdrop-filter: var(--app-header-backdrop-filter, none);
|
||||
padding: var(--header-bar-padding);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ export class HaMarkdown extends LitElement {
|
||||
display: block;
|
||||
}
|
||||
ha-markdown-element {
|
||||
-ms-user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
ha-markdown-element > *:first-child {
|
||||
margin-top: 0;
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { mdiDelete, mdiPlayBox, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { MediaPickedEvent } from "../data/media-player";
|
||||
import {
|
||||
MediaClassBrowserSettings,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../data/selector";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-form/ha-form";
|
||||
import type { SchemaUnion } from "./ha-form/types";
|
||||
import "./media-player/ha-media-browser-thumbnail";
|
||||
import { showMediaBrowserDialog } from "./media-player/show-media-browser-dialog";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import "./ha-picture-upload";
|
||||
|
||||
const MANUAL_SCHEMA = [
|
||||
{ name: "media_content_id", required: false, selector: { text: {} } },
|
||||
{ name: "media_content_type", required: false, selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
const INCLUDE_DOMAINS = ["media_player"];
|
||||
|
||||
const EMPTY_FORM = {};
|
||||
|
||||
@customElement("ha-media-item-picker")
|
||||
export class HaMediaItemPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: MediaSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: MediaSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public required = true;
|
||||
|
||||
@property({ type: Boolean }) public multiple = false;
|
||||
|
||||
@property({ attribute: false }) public context?: {
|
||||
filter_entity?: string | string[];
|
||||
};
|
||||
|
||||
private _contextEntities: string[] | undefined;
|
||||
|
||||
private get _hasAccept(): boolean {
|
||||
return !!this.selector?.media?.accept?.length;
|
||||
}
|
||||
|
||||
willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("context")) {
|
||||
if (!this._hasAccept) {
|
||||
this._contextEntities = ensureArray(this.context?.filter_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const entityId = this._getActiveEntityId();
|
||||
|
||||
const stateObj = entityId ? this.hass.states[entityId] : undefined;
|
||||
|
||||
const supportsBrowse =
|
||||
!entityId ||
|
||||
(stateObj &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
|
||||
|
||||
if (this.selector.media?.image_upload && !this.value) {
|
||||
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${null}
|
||||
.contentIdHelper=${this.selector.media?.content_id_helper}
|
||||
select-media
|
||||
full-media
|
||||
@media-picked=${this._pictureUploadMediaPicked}
|
||||
></ha-picture-upload>`;
|
||||
}
|
||||
|
||||
const hideEntityPicker =
|
||||
this._hasAccept ||
|
||||
(this._contextEntities && this._contextEntities.length <= 1);
|
||||
return html`
|
||||
${
|
||||
hideEntityPicker
|
||||
? nothing
|
||||
: html`
|
||||
<ha-entity-picker
|
||||
.value=${entityId}
|
||||
.label=${
|
||||
this.label ||
|
||||
this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media_player"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.required=${this.required}
|
||||
.hideClearIcon=${!!this._contextEntities}
|
||||
.includeDomains=${INCLUDE_DOMAINS}
|
||||
.includeEntities=${this._contextEntities}
|
||||
.allowCustomEntity=${!this._contextEntities}
|
||||
@value-changed=${this._entityChanged}
|
||||
></ha-entity-picker>
|
||||
`
|
||||
}
|
||||
${
|
||||
!supportsBrowse
|
||||
? html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.components.selectors.media.browse_not_supported"
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.value || EMPTY_FORM}
|
||||
.schema=${MANUAL_SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
></ha-form>
|
||||
`
|
||||
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-card
|
||||
outlined
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title || this.value.media_content_id
|
||||
}
|
||||
@click=${this._pickMedia}
|
||||
@keydown=${this._handleKeyDown}
|
||||
class=${
|
||||
this.disabled || (!entityId && !this._hasAccept)
|
||||
? "disabled"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div class="content-container">
|
||||
<div class="thumbnail">
|
||||
${
|
||||
this.value?.metadata?.thumbnail
|
||||
? html`
|
||||
<div
|
||||
class="${classMap({
|
||||
"centered-image":
|
||||
!!this.value.metadata.media_class &&
|
||||
["app", "directory"].includes(
|
||||
this.value.metadata.media_class
|
||||
),
|
||||
})}
|
||||
image"
|
||||
>
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${this.value.metadata.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="icon-holder image">
|
||||
<ha-svg-icon
|
||||
class="folder"
|
||||
.path=${
|
||||
!this.value?.media_content_id
|
||||
? mdiPlus
|
||||
: this.value?.metadata?.media_class
|
||||
? MediaClassBrowserSettings[
|
||||
this.value.metadata.media_class ===
|
||||
"directory"
|
||||
? this.value.metadata
|
||||
.children_media_class ||
|
||||
this.value.metadata.media_class
|
||||
: this.value.metadata.media_class
|
||||
].icon
|
||||
: mdiPlayBox
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="title">
|
||||
${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title ||
|
||||
this.value.media_content_id
|
||||
}
|
||||
</div>
|
||||
${
|
||||
!this.multiple || !this.value
|
||||
? nothing
|
||||
: html`
|
||||
<div class="delete">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._clearValue}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
${
|
||||
this.selector.media?.image_upload
|
||||
? html`<div>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
variant="danger"
|
||||
@click=${this._clearValue}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.picture-upload.clear_picture"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
${
|
||||
hideEntityPicker && this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}`);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}_detail`);
|
||||
|
||||
private _entityChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!this._hasAccept && this.context?.filter_entity) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
metadata: {
|
||||
browse_entity_id: ev.detail.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
entity_id: ev.detail.value,
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _pickMedia() {
|
||||
showMediaBrowserDialog(this, {
|
||||
action: "pick",
|
||||
entityId: this._getActiveEntityId(),
|
||||
navigateIds: this.value?.metadata?.navigateIds,
|
||||
accept: this.selector.media?.accept,
|
||||
defaultId: this.value?.media_content_id,
|
||||
defaultType: this.value?.media_content_type,
|
||||
hideContentType: this.selector.media?.hide_content_type,
|
||||
contentIdHelper: this.selector.media?.content_id_helper,
|
||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
...(!this._hasAccept && this.context?.filter_entity
|
||||
? { browse_entity_id: this._getActiveEntityId() }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getActiveEntityId(): string | undefined {
|
||||
const metaId = this.value?.metadata?.browse_entity_id;
|
||||
return (
|
||||
this.value?.entity_id ||
|
||||
(metaId && this._contextEntities?.includes(metaId) && metaId) ||
|
||||
this._contextEntities?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._pickMedia();
|
||||
}
|
||||
}
|
||||
|
||||
private _pictureUploadMediaPicked(ev) {
|
||||
const pickedMedia = ev.detail as MediaPickedEvent;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _clearValue(ev) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: background-color 180ms ease-in-out;
|
||||
min-height: 56px;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-card:hover:not(.disabled),
|
||||
ha-card:focus:not(.disabled) {
|
||||
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
ha-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
ha-card.disabled {
|
||||
pointer-events: none;
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
.content-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
ha-card .thumbnail {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .image {
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
.folder {
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
--ha-media-browser-thumbnail-fit: cover;
|
||||
}
|
||||
.centered-image {
|
||||
margin: 4px;
|
||||
--ha-media-browser-thumbnail-fit: contain;
|
||||
}
|
||||
.icon-holder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-media-item-picker": HaMediaItemPicker;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
@@ -74,6 +75,10 @@ type PickerComboBoxRowElement = HTMLDivElement & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
// Under this count the list is rendered without the virtualizer, so it can size the
|
||||
// popover to its content instead of filling a fixed height.
|
||||
const MAX_PLAIN_LIST_ITEMS = 12;
|
||||
|
||||
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
|
||||
const PADDING_ID = "___padding___";
|
||||
|
||||
@@ -153,6 +158,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
|
||||
|
||||
/**
|
||||
* Whether the surface holding the list is done animating in. Defaults to
|
||||
* true so direct embedders render immediately; ha-generic-picker sets it
|
||||
* once its popover has opened, so the virtualizer never measures rows
|
||||
* through the opening animation's scale.
|
||||
*/
|
||||
@property({ type: Boolean }) public shown = true;
|
||||
|
||||
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
|
||||
@property({ attribute: false }) public sections?: (
|
||||
| {
|
||||
@@ -178,6 +191,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
|
||||
|
||||
@query(".plain-list") private _plainListElement?: HTMLElement;
|
||||
|
||||
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
|
||||
|
||||
@state()
|
||||
@@ -186,6 +201,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@state() private _items: PickerComboBoxItem[] = [];
|
||||
|
||||
@state() private _plainList = false;
|
||||
|
||||
@state() private _selectedSection?: string;
|
||||
|
||||
public setFieldValue(value: string) {
|
||||
@@ -195,7 +212,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
return this.virtualizerElement as HTMLElement | null;
|
||||
return this._listElement ?? null;
|
||||
}
|
||||
|
||||
private get _listElement(): HTMLElement | undefined {
|
||||
return this._plainList ? this._plainListElement : this.virtualizerElement;
|
||||
}
|
||||
|
||||
@state() private _sectionTitle?: string;
|
||||
@@ -221,10 +242,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
public willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
loadVirtualizer();
|
||||
this._selectedSection = this.selectedSection;
|
||||
this._allItems = this._getItems();
|
||||
this._items = this._allItems;
|
||||
this._updateListMode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +259,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (!this._search || this.sections?.length) {
|
||||
this._items = this._allItems;
|
||||
}
|
||||
this._updateListMode();
|
||||
}
|
||||
|
||||
// Filtering keeps the mode it opened with, only the full list decides it.
|
||||
private _updateListMode() {
|
||||
this._plainList =
|
||||
!this.sections?.length && this._allItems.length <= MAX_PLAIN_LIST_ITEMS;
|
||||
if (!this._plainList) {
|
||||
loadVirtualizer();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -271,36 +302,61 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="virtualizer-wrapper">
|
||||
<lit-virtualizer
|
||||
.keyFunction=${this._keyFunction}
|
||||
tabindex="0"
|
||||
scroller
|
||||
.items=${this._items}
|
||||
.renderItem=${this._renderItem}
|
||||
style="min-height: 36px;"
|
||||
class=${this._listScrolled ? "scrolled" : ""}
|
||||
.layout=${
|
||||
this.value && this._valuePinned
|
||||
? {
|
||||
pin: {
|
||||
index: this._getInitialSelectedIndex(),
|
||||
block: "center",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@unpinned=${this._handleUnpinned}
|
||||
@scroll=${this._onScrollList}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetSelectedItem}
|
||||
@visibilityChanged=${this._visibilityChanged}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
<div class="list-wrapper ${this._plainList ? "" : "virtualized"}">
|
||||
${this._plainList ? this._renderPlainList() : this._renderVirtualList()}
|
||||
${this.renderScrollableFades()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderPlainList() {
|
||||
return html`
|
||||
<div
|
||||
class="plain-list ${this._listScrolled ? "scrolled" : ""}"
|
||||
tabindex="0"
|
||||
@scroll=${this._onScrollList}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetSelectedItem}
|
||||
>
|
||||
${repeat(this._items, this._keyFunction, this._renderItem)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderVirtualList() {
|
||||
// The virtualizer measures its rows, so it must not do it through the scale
|
||||
// the surface animates in with.
|
||||
if (!this.shown) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<lit-virtualizer
|
||||
.keyFunction=${this._keyFunction}
|
||||
tabindex="0"
|
||||
scroller
|
||||
.items=${this._items}
|
||||
.renderItem=${this._renderItem}
|
||||
style="min-height: 36px;"
|
||||
class=${this._listScrolled ? "scrolled" : ""}
|
||||
.layout=${
|
||||
this.value && this._valuePinned
|
||||
? {
|
||||
pin: {
|
||||
index: this._getInitialSelectedIndex(),
|
||||
block: "center",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@unpinned=${this._handleUnpinned}
|
||||
@scroll=${this._onScrollList}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetSelectedItem}
|
||||
@visibilityChanged=${this._visibilityChanged}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSectionButtons() {
|
||||
if (!this.sections || this.sections.length === 0) {
|
||||
return nothing;
|
||||
@@ -564,7 +620,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this._items = this._getItems();
|
||||
|
||||
// Reset scroll position when filter changes
|
||||
this.virtualizerElement?.element(0)?.scrollIntoView();
|
||||
this._resetListScroll();
|
||||
}
|
||||
|
||||
private _registerKeyboardShortcuts() {
|
||||
@@ -578,6 +634,26 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _resetListScroll() {
|
||||
if (this._plainList) {
|
||||
this._listElement?.scrollTo({ top: 0 });
|
||||
return;
|
||||
}
|
||||
this.virtualizerElement?.element(0)?.scrollIntoView();
|
||||
}
|
||||
|
||||
private _scrollRowIntoView(index: number) {
|
||||
if (this._plainList) {
|
||||
this._listElement
|
||||
?.querySelector(`#list-item-${index}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
this.virtualizerElement?.element(index)?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
|
||||
private _focusList() {
|
||||
if (this._selectedItemIndex === -1) {
|
||||
this._initializeSelectedIndex();
|
||||
@@ -589,7 +665,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
* or fall back to the first item when searching (skipping section titles).
|
||||
*/
|
||||
private _initializeSelectedIndex(): void {
|
||||
if (!this.virtualizerElement?.items?.length) {
|
||||
if (!this._items.length) {
|
||||
return;
|
||||
}
|
||||
const initialIndex = this._getInitialSelectedIndex();
|
||||
@@ -599,11 +675,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
let index = initialIndex;
|
||||
// Skip section titles (strings)
|
||||
if (typeof this.virtualizerElement.items[index] === "string") {
|
||||
if (typeof this._items[index] === "string") {
|
||||
index += 1;
|
||||
}
|
||||
// Bounds check: ensure index is valid after skipping section title
|
||||
if (index >= this.virtualizerElement.items.length) {
|
||||
if (index >= this._items.length) {
|
||||
return;
|
||||
}
|
||||
this._selectedItemIndex = index;
|
||||
@@ -613,13 +689,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
private _selectNextItem = (ev?: KeyboardEvent) => {
|
||||
ev?.stopPropagation();
|
||||
ev?.preventDefault();
|
||||
if (!this.virtualizerElement) {
|
||||
if (!this._listElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._searchFieldElement?.focus();
|
||||
|
||||
const items = this.virtualizerElement.items as PickerComboBoxItem[];
|
||||
const items = this._items;
|
||||
|
||||
const maxItems = items.length - 1;
|
||||
|
||||
@@ -661,14 +737,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
private _selectPreviousItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!this.virtualizerElement) {
|
||||
if (!this._listElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._selectedItemIndex > 0) {
|
||||
const nextIndex = this._selectedItemIndex - 1;
|
||||
|
||||
const items = this.virtualizerElement.items as PickerComboBoxItem[];
|
||||
const items = this._items;
|
||||
|
||||
if (!items[nextIndex]) {
|
||||
return;
|
||||
@@ -690,13 +766,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _selectFirstItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
|
||||
if (!this._listElement || !this._items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = 0;
|
||||
|
||||
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
|
||||
if (typeof this._items[nextIndex] === "string") {
|
||||
this._selectedItemIndex = nextIndex + 1;
|
||||
} else {
|
||||
this._selectedItemIndex = nextIndex;
|
||||
@@ -707,13 +783,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _selectLastItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
|
||||
if (!this._listElement || !this._items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = this.virtualizerElement.items.length - 1;
|
||||
const nextIndex = this._items.length - 1;
|
||||
|
||||
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
|
||||
if (typeof this._items[nextIndex] === "string") {
|
||||
this._selectedItemIndex = nextIndex - 1;
|
||||
} else {
|
||||
this._selectedItemIndex = nextIndex;
|
||||
@@ -723,16 +799,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
};
|
||||
|
||||
private _scrollToSelectedItem = () => {
|
||||
this.virtualizerElement
|
||||
?.querySelector(".selected")
|
||||
?.classList.remove("selected");
|
||||
this._listElement?.querySelector(".selected")?.classList.remove("selected");
|
||||
|
||||
this.virtualizerElement
|
||||
?.element(this._selectedItemIndex)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
this._scrollRowIntoView(this._selectedItemIndex);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.virtualizerElement
|
||||
this._listElement
|
||||
?.querySelector(`#list-item-${this._selectedItemIndex}`)
|
||||
?.classList.add("selected");
|
||||
});
|
||||
@@ -749,14 +821,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
private _pickItem = (ev: KeyboardEvent, newTab: boolean) => {
|
||||
ev.stopPropagation();
|
||||
if (
|
||||
this.virtualizerElement?.items?.length !== undefined &&
|
||||
this.virtualizerElement.items.length < 4 && // it still can have a section title and a padding item
|
||||
this.virtualizerElement.items.filter((item) => typeof item !== "string")
|
||||
.length === 1
|
||||
this._items.length < 4 && // it still can have a section title and a padding item
|
||||
this._items.filter((item) => typeof item !== "string").length === 1
|
||||
) {
|
||||
(
|
||||
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
|
||||
).forEach((item, index) => {
|
||||
this._items.forEach((item, index) => {
|
||||
if (typeof item !== "string" && !item.disabled) {
|
||||
this._fireSelectedEvents(item.id, index, newTab);
|
||||
}
|
||||
@@ -774,18 +842,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
// if filter button is focused
|
||||
ev.preventDefault();
|
||||
|
||||
const item = this.virtualizerElement?.items[
|
||||
this._selectedItemIndex
|
||||
] as PickerComboBoxItem;
|
||||
const item = this._items[this._selectedItemIndex];
|
||||
if (item && !item.disabled) {
|
||||
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
|
||||
}
|
||||
};
|
||||
|
||||
private _resetSelectedItem() {
|
||||
this.virtualizerElement
|
||||
?.querySelector(".selected")
|
||||
?.classList.remove("selected");
|
||||
this._listElement?.querySelector(".selected")?.classList.remove("selected");
|
||||
this._selectedItemIndex = -1;
|
||||
}
|
||||
|
||||
@@ -793,11 +857,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
typeof item === "string" ? item : item?.id;
|
||||
|
||||
private _getInitialSelectedIndex() {
|
||||
if (!this.virtualizerElement || this._search || !this.value) {
|
||||
if (this._search || !this.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const index = this.virtualizerElement.items.findIndex(
|
||||
const index = this._items.findIndex(
|
||||
(item) =>
|
||||
typeof item !== "string" &&
|
||||
(item as PickerComboBoxItem).id === this.value
|
||||
@@ -820,6 +884,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
flex-direction: column;
|
||||
padding-top: var(--ha-space-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:host([clearable]) {
|
||||
@@ -851,23 +916,41 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
.virtualizer-wrapper {
|
||||
.list-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
lit-virtualizer {
|
||||
/* The virtualizer is size contained, so it fills a height rather than
|
||||
providing one. Asking for the whole viewport leaves the popover to cap it. */
|
||||
.list-wrapper.virtualized {
|
||||
flex: 1 1 100vh;
|
||||
}
|
||||
|
||||
/* A sheet has its own height, so the list fills it instead of sizing it. */
|
||||
:host([mode="dialog"]) .list-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
lit-virtualizer:focus-visible {
|
||||
lit-virtualizer,
|
||||
.plain-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plain-list {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
lit-virtualizer:focus-visible,
|
||||
.plain-list:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
lit-virtualizer.scrolled {
|
||||
.scrolled {
|
||||
border-top: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
import { mdiPlayBox, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import type { MediaPickedEvent } from "../../data/media-player";
|
||||
import {
|
||||
MediaClassBrowserSettings,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../ha-form/types";
|
||||
import "../media-player/ha-media-browser-thumbnail";
|
||||
import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog";
|
||||
import "../ha-media-item-picker";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import "../ha-picture-upload";
|
||||
|
||||
const MANUAL_SCHEMA = [
|
||||
{ name: "media_content_id", required: false, selector: { text: {} } },
|
||||
{ name: "media_content_type", required: false, selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
const INCLUDE_DOMAINS = ["media_player"];
|
||||
|
||||
const EMPTY_FORM = {};
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
@customElement("ha-selector-media")
|
||||
export class HaMediaSelector extends LitElement {
|
||||
@@ -35,7 +12,8 @@ export class HaMediaSelector extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public selector!: MediaSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: MediaSelectorValue;
|
||||
@property({ attribute: false }) public value?:
|
||||
MediaSelectorValue | MediaSelectorValue[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@@ -49,367 +27,83 @@ export class HaMediaSelector extends LitElement {
|
||||
filter_entity?: string | string[];
|
||||
};
|
||||
|
||||
private _contextEntities: string[] | undefined;
|
||||
|
||||
private get _hasAccept(): boolean {
|
||||
return !!this.selector?.media?.accept?.length;
|
||||
private _renderPicker(
|
||||
helper?: string,
|
||||
value?: MediaSelectorValue,
|
||||
index?: number,
|
||||
changeCallback?
|
||||
): TemplateResult {
|
||||
return html`<ha-media-item-picker
|
||||
.hass=${this.hass}
|
||||
.context=${this.context}
|
||||
.selector=${this.selector}
|
||||
.value=${value}
|
||||
.label=${index == null ? this.label : undefined}
|
||||
.helper=${helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.multiple=${index != null}
|
||||
@value-changed=${changeCallback}
|
||||
.idx=${index}
|
||||
></ha-media-item-picker>`;
|
||||
}
|
||||
|
||||
willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("context")) {
|
||||
if (!this._hasAccept) {
|
||||
this._contextEntities = ensureArray(this.context?.filter_entity);
|
||||
private _multiValueChanged = (event: CustomEvent) => {
|
||||
event.stopPropagation();
|
||||
const target = event.currentTarget as any;
|
||||
if (!target) return;
|
||||
const idx = target.idx;
|
||||
let newValue: MediaSelectorValue[] | undefined;
|
||||
if (idx === undefined) return;
|
||||
if (!this.value || !Array.isArray(this.value)) {
|
||||
newValue = event.detail.value ? [event.detail.value] : undefined;
|
||||
} else {
|
||||
newValue = [...this.value];
|
||||
if (event.detail.value) {
|
||||
if (idx > this.value.length) {
|
||||
newValue.push(event.detail.value);
|
||||
} else {
|
||||
newValue[idx] = event.detail.value;
|
||||
}
|
||||
} else {
|
||||
newValue.splice(idx, 1);
|
||||
if (newValue.length === 0) {
|
||||
newValue = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: newValue });
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const entityId = this._getActiveEntityId();
|
||||
|
||||
const stateObj = entityId ? this.hass.states[entityId] : undefined;
|
||||
|
||||
const supportsBrowse =
|
||||
!entityId ||
|
||||
(stateObj &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
|
||||
|
||||
if (this.selector.media?.image_upload && !this.value) {
|
||||
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${null}
|
||||
.contentIdHelper=${this.selector.media?.content_id_helper}
|
||||
select-media
|
||||
full-media
|
||||
@media-picked=${this._pictureUploadMediaPicked}
|
||||
></ha-picture-upload>`;
|
||||
if (this.selector.media?.multiple) {
|
||||
const value = ensureArray(this.value);
|
||||
const result: TemplateResult[] = this.label
|
||||
? [html`<label>${this.label}</label>`]
|
||||
: [];
|
||||
if (value) {
|
||||
result.push(
|
||||
...value.map((v, i) =>
|
||||
this._renderPicker(undefined, v, i, this._multiValueChanged)
|
||||
)
|
||||
);
|
||||
}
|
||||
result.push(
|
||||
this._renderPicker(
|
||||
this.helper,
|
||||
undefined,
|
||||
value?.length || 0,
|
||||
this._multiValueChanged
|
||||
)
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
return html`
|
||||
${
|
||||
this._hasAccept ||
|
||||
(this._contextEntities && this._contextEntities.length <= 1)
|
||||
? nothing
|
||||
: html`
|
||||
<ha-entity-picker
|
||||
.value=${entityId}
|
||||
.label=${
|
||||
this.label ||
|
||||
this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media_player"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.required=${this.required}
|
||||
.hideClearIcon=${!!this._contextEntities}
|
||||
.includeDomains=${INCLUDE_DOMAINS}
|
||||
.includeEntities=${this._contextEntities}
|
||||
.allowCustomEntity=${!this._contextEntities}
|
||||
@value-changed=${this._entityChanged}
|
||||
></ha-entity-picker>
|
||||
`
|
||||
}
|
||||
${
|
||||
!supportsBrowse
|
||||
? html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.components.selectors.media.browse_not_supported"
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.value || EMPTY_FORM}
|
||||
.schema=${MANUAL_SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
></ha-form>
|
||||
`
|
||||
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-card
|
||||
outlined
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title || this.value.media_content_id
|
||||
}
|
||||
@click=${this._pickMedia}
|
||||
@keydown=${this._handleKeyDown}
|
||||
class=${
|
||||
this.disabled || (!entityId && !this._hasAccept)
|
||||
? "disabled"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div class="content-container">
|
||||
<div class="thumbnail">
|
||||
${
|
||||
this.value?.metadata?.thumbnail
|
||||
? html`
|
||||
<div
|
||||
class="${classMap({
|
||||
"centered-image":
|
||||
!!this.value.metadata.media_class &&
|
||||
["app", "directory"].includes(
|
||||
this.value.metadata.media_class
|
||||
),
|
||||
})}
|
||||
image"
|
||||
>
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${this.value.metadata.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="icon-holder image">
|
||||
<ha-svg-icon
|
||||
class="folder"
|
||||
.path=${
|
||||
!this.value?.media_content_id
|
||||
? mdiPlus
|
||||
: this.value?.metadata?.media_class
|
||||
? MediaClassBrowserSettings[
|
||||
this.value.metadata.media_class ===
|
||||
"directory"
|
||||
? this.value.metadata
|
||||
.children_media_class ||
|
||||
this.value.metadata.media_class
|
||||
: this.value.metadata.media_class
|
||||
].icon
|
||||
: mdiPlayBox
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="title">
|
||||
${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title ||
|
||||
this.value.media_content_id
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
${
|
||||
this.selector.media?.clearable
|
||||
? html`<div>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
variant="danger"
|
||||
@click=${this._clearValue}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.picture-upload.clear_picture"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}`);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}_detail`);
|
||||
|
||||
private _entityChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!this._hasAccept && this.context?.filter_entity) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
metadata: {
|
||||
browse_entity_id: ev.detail.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
entity_id: ev.detail.value,
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _pickMedia() {
|
||||
showMediaBrowserDialog(this, {
|
||||
action: "pick",
|
||||
entityId: this._getActiveEntityId(),
|
||||
navigateIds: this.value?.metadata?.navigateIds,
|
||||
accept: this.selector.media?.accept,
|
||||
defaultId: this.value?.media_content_id,
|
||||
defaultType: this.value?.media_content_type,
|
||||
hideContentType: this.selector.media?.hide_content_type,
|
||||
contentIdHelper: this.selector.media?.content_id_helper,
|
||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
...(!this._hasAccept && this.context?.filter_entity
|
||||
? { browse_entity_id: this._getActiveEntityId() }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getActiveEntityId(): string | undefined {
|
||||
const metaId = this.value?.metadata?.browse_entity_id;
|
||||
return (
|
||||
this.value?.entity_id ||
|
||||
(metaId && this._contextEntities?.includes(metaId) && metaId) ||
|
||||
this._contextEntities?.[0]
|
||||
return this._renderPicker(
|
||||
this.helper,
|
||||
Array.isArray(this.value) ? this.value[0] : this.value
|
||||
);
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._pickMedia();
|
||||
}
|
||||
}
|
||||
|
||||
private _pictureUploadMediaPicked(ev) {
|
||||
const pickedMedia = ev.detail as MediaPickedEvent;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _clearValue() {
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: background-color 180ms ease-in-out;
|
||||
min-height: 56px;
|
||||
}
|
||||
ha-card:hover:not(.disabled),
|
||||
ha-card:focus:not(.disabled) {
|
||||
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
ha-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
ha-card.disabled {
|
||||
pointer-events: none;
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
.content-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
ha-card .thumbnail {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .image {
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
.folder {
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
--ha-media-browser-thumbnail-fit: cover;
|
||||
}
|
||||
.centered-image {
|
||||
margin: 4px;
|
||||
--ha-media-browser-thumbnail-fit: contain;
|
||||
}
|
||||
.icon-holder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ObjectSelector } from "../../data/selector";
|
||||
import { formatSelectorValue } from "../../data/selector/format_selector_value";
|
||||
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
|
||||
import type { HaFormSchema } from "../ha-form/types";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-md-list";
|
||||
@@ -237,10 +238,14 @@ export class HaObjectSelector extends LitElement {
|
||||
private async _addItem(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const schema = this._schema(this.selector);
|
||||
|
||||
const newItem = await showFormDialog(this, {
|
||||
title: this.hass.localize("ui.common.add"),
|
||||
schema: this._schema(this.selector),
|
||||
data: {},
|
||||
schema,
|
||||
data: computeInitialHaFormData(schema, {
|
||||
skipUnsupportedSelectors: true,
|
||||
}),
|
||||
computeLabel: this._computeLabel,
|
||||
computeHelper: this._computeHelper,
|
||||
submitText: this.hass.localize("ui.common.add"),
|
||||
@@ -293,7 +298,7 @@ export class HaObjectSelector extends LitElement {
|
||||
const index = ev.currentTarget.index;
|
||||
|
||||
if (!this.selector.object!.multiple) {
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
fireEvent(this, "value-changed", { value: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,10 @@ const SELECTOR_SCHEMAS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const,
|
||||
number: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { StateClassSelector } from "../../data/selector";
|
||||
import "../ha-state-class-picker";
|
||||
import { SENSOR_STATE_CLASSES } from "../../data/sensor_entity_constants";
|
||||
|
||||
@customElement("ha-selector-state_class")
|
||||
export class HaStateClassSelector extends LitElement {
|
||||
@property({ attribute: false }) public selector!: StateClassSelector;
|
||||
|
||||
@property() public value?: string | string[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-state-class-picker
|
||||
.value=${this.value}
|
||||
.multiple=${this.selector.state_class?.multiple ?? false}
|
||||
.stateClasses=${this.selector.state_class?.state_classes ?? SENSOR_STATE_CLASSES}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-state-class-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-state-class-picker {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-state_class": HaStateClassSelector;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import type { Selector } from "../../data/selector";
|
||||
import type { Selector, SelectorType } from "../../data/selector";
|
||||
import {
|
||||
handleLegacyDeviceSelector,
|
||||
handleLegacyEntitySelector,
|
||||
@@ -49,6 +49,7 @@ const LOAD_ELEMENTS = {
|
||||
selector: () => import("./ha-selector-selector"),
|
||||
serial_port: () => import("./ha-selector-serial-port"),
|
||||
state: () => import("./ha-selector-state"),
|
||||
state_class: () => import("./ha-selector-state-class"),
|
||||
backup_location: () => import("./ha-selector-backup-location"),
|
||||
stt: () => import("./ha-selector-stt"),
|
||||
target: () => import("./ha-selector-target"),
|
||||
@@ -70,7 +71,7 @@ const LOAD_ELEMENTS = {
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
ui_time_format: () => import("./ha-selector-ui-time-format"),
|
||||
};
|
||||
} satisfies Record<SelectorType, () => Promise<unknown>>;
|
||||
|
||||
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { toggleAttribute } from "../common/dom/toggle_attribute";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import { throttle } from "../common/util/throttle";
|
||||
@@ -296,7 +295,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("alwaysExpand")) {
|
||||
toggleAttribute(this, "expanded", this.alwaysExpand);
|
||||
this.toggleAttribute("expanded", this.alwaysExpand);
|
||||
}
|
||||
if (!changedProps.has("hass")) {
|
||||
return;
|
||||
@@ -665,9 +664,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
background-color: var(--sidebar-background-color);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -897,7 +894,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
}
|
||||
|
||||
.menu ha-icon-button {
|
||||
-webkit-transform: scaleX(var(--scale-direction));
|
||||
transform: scaleX(var(--scale-direction));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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 { SENSOR_STATE_CLASSES } from "../data/sensor_entity_constants";
|
||||
import { computeStateClassName } from "../data/entity/state_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 getStateClassOptions = (
|
||||
localize: LocalizeFunc,
|
||||
stateClasses: string[]
|
||||
): PickerComboBoxItem[] =>
|
||||
SENSOR_STATE_CLASSES.filter((stateClass) =>
|
||||
stateClasses.includes(stateClass)
|
||||
).map((stateClass) => {
|
||||
const primary = computeStateClassName(localize, stateClass);
|
||||
return { id: stateClass, primary, sorting_label: primary };
|
||||
});
|
||||
|
||||
@customElement("ha-state-class-picker")
|
||||
export class HaStateClassPicker extends LitElement {
|
||||
@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;
|
||||
|
||||
@property({ attribute: false }) public stateClasses?: string[];
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
private _translationsLoaded = false;
|
||||
|
||||
protected willUpdate() {
|
||||
if (this._translationsLoaded || !this._i18n) {
|
||||
return;
|
||||
}
|
||||
this._translationsLoaded = true;
|
||||
this._i18n.loadBackendTranslation("entity_component", "sensor");
|
||||
}
|
||||
|
||||
private get _value(): string[] {
|
||||
return this.value ? ensureArray(this.value) : [];
|
||||
}
|
||||
|
||||
private _stateClassName(stateClass: string): string {
|
||||
return this._i18n
|
||||
? computeStateClassName(this._i18n.localize, stateClass)
|
||||
: stateClass;
|
||||
}
|
||||
|
||||
private _options = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc | undefined,
|
||||
stateClasses: string[]
|
||||
): PickerComboBoxItem[] =>
|
||||
localize ? getStateClassOptions(localize, stateClasses) : []
|
||||
);
|
||||
|
||||
private _availableOptions = memoizeOne(
|
||||
(options: PickerComboBoxItem[], selected: string[]) =>
|
||||
options.filter((option) => !selected.includes(option.id))
|
||||
);
|
||||
|
||||
private _getItems = () => {
|
||||
const options = this._options(
|
||||
this._i18n?.localize,
|
||||
this.stateClasses || SENSOR_STATE_CLASSES
|
||||
);
|
||||
return this.multiple
|
||||
? this._availableOptions(options, this._value)
|
||||
: options;
|
||||
};
|
||||
|
||||
private _valueRenderer = (value: string) =>
|
||||
html`<span slot="headline">${this._stateClassName(value)}</span>`;
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return this._i18n
|
||||
? this._i18n.localize("ui.components.state-class-picker.no_match", {
|
||||
term,
|
||||
})
|
||||
: html`No state classes found for ${term}`;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const localize = this._i18n?.localize;
|
||||
const emptyLabel = localize?.(
|
||||
"ui.components.state-class-picker.no_state_classes"
|
||||
);
|
||||
|
||||
if (this.multiple) {
|
||||
const value = this._value;
|
||||
return html`
|
||||
${
|
||||
value.length
|
||||
? html`
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
value,
|
||||
(stateClass) => stateClass,
|
||||
(stateClass) => {
|
||||
const label = this._stateClassName(stateClass);
|
||||
return html`
|
||||
<ha-input-chip
|
||||
.item=${stateClass}
|
||||
.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.state-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.state-class-picker.state_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 stateClass = ev.detail.value;
|
||||
if (!stateClass || this._value.includes(stateClass)) {
|
||||
return;
|
||||
}
|
||||
this._setValue([...this._value, stateClass]);
|
||||
}
|
||||
|
||||
private _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const stateClass = (ev.currentTarget as HTMLElement & { item: string })
|
||||
.item;
|
||||
this._setValue(this._value.filter((item) => item !== stateClass));
|
||||
}
|
||||
|
||||
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-state-class-picker": HaStateClassPicker;
|
||||
}
|
||||
}
|
||||
+133
-105
@@ -9,7 +9,8 @@ import {
|
||||
} from "../resources/theme/color/color.globals";
|
||||
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
|
||||
import "./ha-button";
|
||||
import "./ha-settings-row";
|
||||
import "./item/ha-list-item-base";
|
||||
import "./list/ha-list-base";
|
||||
import "./ha-theme-picker";
|
||||
import "./input/ha-input";
|
||||
import "./radio/ha-radio-group";
|
||||
@@ -25,6 +26,7 @@ export interface ThemeSettingsLabels {
|
||||
autoMode?: string;
|
||||
lightMode?: string;
|
||||
darkMode?: string;
|
||||
colors?: string;
|
||||
primaryColor?: string;
|
||||
accentColor?: string;
|
||||
reset?: string;
|
||||
@@ -42,7 +44,7 @@ export class HaThemeSettings extends LitElement {
|
||||
|
||||
@property() public heading?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ attribute: "include-default", type: Boolean })
|
||||
public includeDefault = false;
|
||||
@@ -62,98 +64,121 @@ export class HaThemeSettings extends LitElement {
|
||||
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
|
||||
: this.hass.themes.default_theme;
|
||||
|
||||
const showDarkMode =
|
||||
curTheme === HOME_ASSISTANT_THEME ||
|
||||
(curThemeIsUseDefault &&
|
||||
this.hass.themes.default_dark_theme &&
|
||||
this.hass.themes.default_theme) ||
|
||||
this._supportsModeSelection(curTheme);
|
||||
|
||||
return html`
|
||||
<ha-settings-row .narrow=${this.narrow} ?empty=${!this.showThemePicker}>
|
||||
${
|
||||
this.heading
|
||||
? html`<span slot="heading">${this.heading}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.description
|
||||
? html`<span slot="description">${this.description}</span>`
|
||||
: nothing
|
||||
}
|
||||
<ha-list-base>
|
||||
${
|
||||
this.showThemePicker
|
||||
? html`
|
||||
<ha-theme-picker
|
||||
.label=${this.labels?.theme}
|
||||
.noThemeLabel=${this.labels?.noTheme}
|
||||
.value=${themeSettings?.theme || undefined}
|
||||
.disabled=${this.themePickerDisabled}
|
||||
?include-default=${this.includeDefault}
|
||||
@value-changed=${this._handleThemeSelection}
|
||||
></ha-theme-picker>
|
||||
<ha-list-item-base>
|
||||
${
|
||||
this.heading
|
||||
? html`<span slot="headline">${this.heading}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.description
|
||||
? html`<span slot="supporting-text"
|
||||
>${this.description}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<ha-theme-picker
|
||||
slot="end"
|
||||
.label=${this.labels?.theme}
|
||||
.noThemeLabel=${this.labels?.noTheme}
|
||||
.value=${themeSettings?.theme || undefined}
|
||||
.disabled=${this.themePickerDisabled}
|
||||
?include-default=${this.includeDefault}
|
||||
@value-changed=${this._handleThemeSelection}
|
||||
></ha-theme-picker>
|
||||
</ha-list-item-base>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-settings-row>
|
||||
${
|
||||
curTheme === HOME_ASSISTANT_THEME ||
|
||||
(curThemeIsUseDefault &&
|
||||
this.hass.themes.default_dark_theme &&
|
||||
this.hass.themes.default_theme) ||
|
||||
this._supportsModeSelection(curTheme)
|
||||
? html`<div class="inputs">
|
||||
<ha-radio-group
|
||||
@change=${this._handleDarkMode}
|
||||
name="dark_mode"
|
||||
.ariaLabel=${this.labels?.mode ?? "Theme mode"}
|
||||
.value=${
|
||||
themeSettings?.dark === undefined
|
||||
? "auto"
|
||||
: themeSettings.dark
|
||||
? "dark"
|
||||
: "light"
|
||||
${
|
||||
showDarkMode
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<span slot="headline"
|
||||
>${this.labels?.mode ?? "Theme mode"}</span
|
||||
>
|
||||
<ha-radio-group
|
||||
slot="end"
|
||||
@change=${this._handleDarkMode}
|
||||
name="dark_mode"
|
||||
.value=${
|
||||
themeSettings?.dark === undefined
|
||||
? "auto"
|
||||
: themeSettings.dark
|
||||
? "dark"
|
||||
: "light"
|
||||
}
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ha-radio-option value="auto">
|
||||
${this.labels?.autoMode ?? "Auto"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="light">
|
||||
${this.labels?.lightMode ?? "Light"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="dark">
|
||||
${this.labels?.darkMode ?? "Dark"}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
</ha-list-item-base>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
curTheme === HOME_ASSISTANT_THEME
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<span slot="headline"
|
||||
>${this.labels?.colors ?? "Custom colors"}</span
|
||||
>
|
||||
<div slot="end" class="color-pickers">
|
||||
<ha-input
|
||||
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
|
||||
type="color"
|
||||
.label=${this.labels?.primaryColor ?? "Primary color"}
|
||||
.name=${"primaryColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
.value=${themeSettings?.accentColor || DefaultAccentColor}
|
||||
type="color"
|
||||
.label=${this.labels?.accentColor ?? "Accent color"}
|
||||
.name=${"accentColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
</div>
|
||||
</ha-list-item-base>
|
||||
${
|
||||
themeSettings?.primaryColor || themeSettings?.accentColor
|
||||
? html`
|
||||
<div class="reset-row">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._resetColors}
|
||||
>
|
||||
${this.labels?.reset ?? "Reset"}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ha-radio-option value="auto">
|
||||
${this.labels?.autoMode ?? "Auto"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="light">
|
||||
${this.labels?.lightMode ?? "Light"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="dark">
|
||||
${this.labels?.darkMode ?? "Dark"}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
${
|
||||
curTheme === HOME_ASSISTANT_THEME
|
||||
? html`<div class="color-pickers">
|
||||
<ha-input
|
||||
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
|
||||
type="color"
|
||||
.label=${this.labels?.primaryColor ?? "Primary color"}
|
||||
.name=${"primaryColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
.value=${themeSettings?.accentColor || DefaultAccentColor}
|
||||
type="color"
|
||||
.label=${this.labels?.accentColor ?? "Accent color"}
|
||||
.name=${"accentColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
${
|
||||
themeSettings?.primaryColor ||
|
||||
themeSettings?.accentColor
|
||||
? html` <ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._resetColors}
|
||||
>
|
||||
${this.labels?.reset ?? "Reset"}
|
||||
</ha-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-base>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -231,33 +256,36 @@ export class HaThemeSettings extends LitElement {
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin: 0 var(--ha-space-3);
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-block: var(--ha-space-2);
|
||||
}
|
||||
ha-radio-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-inline-end: var(--ha-space-3);
|
||||
ha-theme-picker {
|
||||
min-width: 150px;
|
||||
}
|
||||
.color-pickers {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
ha-input {
|
||||
min-width: 75px;
|
||||
flex-grow: 1;
|
||||
margin: 0 var(--ha-space-1);
|
||||
.color-pickers ha-input {
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
ha-theme-picker {
|
||||
display: block;
|
||||
:host([narrow]) ha-list-item-base::part(base) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
:host([narrow]) ha-list-item-base::part(end) {
|
||||
width: 100%;
|
||||
}
|
||||
:host([narrow]) .color-pickers {
|
||||
width: 100%;
|
||||
}
|
||||
.reset-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
padding-bottom: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,9 @@ export class HaInputCopy extends LitElement {
|
||||
}
|
||||
|
||||
private _focusInput(ev: HASSDomCurrentTargetEvent<HaInput>) {
|
||||
if (!this.readonly) {
|
||||
return;
|
||||
}
|
||||
const inputElement = ev.currentTarget as HaInput;
|
||||
inputElement.select();
|
||||
}
|
||||
|
||||
@@ -914,8 +914,6 @@ export class HaMap extends ReactiveElement {
|
||||
#map.clickable:active,
|
||||
#map:active {
|
||||
cursor: grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
/* Only the raster fallback is inverted for dark mode, the vector style
|
||||
ships its own dark cartography. */
|
||||
|
||||
@@ -564,75 +564,73 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.primaryEntitiesOnly
|
||||
);
|
||||
|
||||
let referencedAreas = entries.referenced_areas;
|
||||
const hiddenAreaIds: string[] = [];
|
||||
if (this.type === "floor" || this.type === "label") {
|
||||
entries.referenced_areas = entries.referenced_areas.filter(
|
||||
(area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenAreaIds.push(area_id);
|
||||
referencedAreas = referencedAreas.filter((area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
return false;
|
||||
}
|
||||
);
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenAreaIds.push(area_id);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
let referencedDevices = entries.referenced_devices;
|
||||
const hiddenDeviceIds: string[] = [];
|
||||
if (
|
||||
this.type === "floor" ||
|
||||
this.type === "area" ||
|
||||
this.type === "label"
|
||||
) {
|
||||
entries.referenced_devices = entries.referenced_devices.filter(
|
||||
(device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
referencedDevices = referencedDevices.filter((device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
);
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
entries.referenced_entities = entries.referenced_entities.filter(
|
||||
const referencedEntities = entries.referenced_entities.filter(
|
||||
(entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
// Core can reference entities that are absent from the display
|
||||
@@ -647,9 +645,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
(this.type === "area" && entity.area_id === this.itemId) ||
|
||||
(this.type === "floor" &&
|
||||
entity.area_id &&
|
||||
entries.referenced_areas.includes(entity.area_id)) ||
|
||||
referencedAreas.includes(entity.area_id)) ||
|
||||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
|
||||
entries.referenced_devices.includes(entity.device_id || "")
|
||||
referencedDevices.includes(entity.device_id || "")
|
||||
) {
|
||||
return entityRegMeetsFilter(
|
||||
entity,
|
||||
@@ -664,7 +662,12 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
this._entries = entries;
|
||||
this._entries = {
|
||||
...entries,
|
||||
referenced_areas: referencedAreas,
|
||||
referenced_devices: referencedDevices,
|
||||
referenced_entities: referencedEntities,
|
||||
};
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to extract target", e);
|
||||
|
||||
@@ -520,7 +520,7 @@ export class HatScriptGraph extends LitElement {
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray(node.sequence).map((action, i) =>
|
||||
${ensureArray(node.sequence ?? []).map((action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/sequence/${i}`,
|
||||
|
||||
@@ -15,7 +15,6 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import { relativeTime } from "../../common/datetime/relative_time";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { toggleAttribute } from "../../common/dom/toggle_attribute";
|
||||
import { fullEntitiesContext } from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
@@ -870,7 +869,7 @@ export class HaAutomationTracer extends LitElement {
|
||||
this.shadowRoot!.querySelectorAll<HaTimeline>(
|
||||
"ha-timeline[data-path]"
|
||||
).forEach((el) => {
|
||||
toggleAttribute(el, "selected", this.selectedPath === el.dataset.path);
|
||||
el.toggleAttribute("selected", this.selectedPath === el.dataset.path);
|
||||
if (!this.allowPick || el.tabIndex === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
+28
-9
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
computeCssColor,
|
||||
isValidColorString,
|
||||
resolveThemeColor,
|
||||
} from "../common/color/compute-color";
|
||||
import { getColorByIndex } from "../common/color/colors";
|
||||
import { getContrastedColorHex, isOpaqueColor } from "../common/color/rgb";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -13,6 +15,7 @@ export interface Calendar {
|
||||
entity_id: string;
|
||||
name?: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
/** Object used to render a calendar event in fullcalendar. */
|
||||
@@ -22,6 +25,7 @@ export interface CalendarEvent {
|
||||
end?: string;
|
||||
backgroundColor?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
calendar: string;
|
||||
eventData: CalendarEventData;
|
||||
[key: string]: any;
|
||||
@@ -107,6 +111,28 @@ export const fetchCalendarEvents = async (
|
||||
return { events: calEvents, errors };
|
||||
};
|
||||
|
||||
export const getCalendarColors = (
|
||||
color: string | null | undefined,
|
||||
index: number,
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): { backgroundColor: string; textColor?: string } => {
|
||||
// Fall back to a color by index when the entity has none set
|
||||
const resolved =
|
||||
color && isValidColorString(color)
|
||||
? color
|
||||
: getColorByIndex(index, computedStyles);
|
||||
// A theme color stays a CSS variable in the background, so the text color
|
||||
// comes from what that variable holds for this element.
|
||||
const background = resolveThemeColor(resolved, computedStyles);
|
||||
return {
|
||||
backgroundColor: computeCssColor(resolved),
|
||||
// A background we cannot measure keeps the color fullcalendar picks itself
|
||||
textColor: isOpaqueColor(background)
|
||||
? getContrastedColorHex(background)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCalendars = (
|
||||
hass: HomeAssistant,
|
||||
element: Element,
|
||||
@@ -127,18 +153,10 @@ export const getCalendars = (
|
||||
.map((eid, idx) => {
|
||||
const stateObj = hass.states[eid];
|
||||
const entityColor = entityOptionsMap.get(eid)?.calendar?.color;
|
||||
let backgroundColor: string;
|
||||
// Validate and use the color from entity registry if valid
|
||||
if (entityColor && isValidColorString(entityColor)) {
|
||||
backgroundColor = computeCssColor(entityColor);
|
||||
} else {
|
||||
// Fall back to default color by index
|
||||
backgroundColor = getColorByIndex(idx, computedStyles);
|
||||
}
|
||||
return {
|
||||
...stateObj,
|
||||
name: computeStateName(stateObj),
|
||||
backgroundColor,
|
||||
...getCalendarColors(entityColor, idx, computedStyles),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -268,6 +286,7 @@ export const normalizeSubscriptionEventData = (
|
||||
title: eventData.summary,
|
||||
backgroundColor: calendar.backgroundColor,
|
||||
borderColor: calendar.backgroundColor,
|
||||
textColor: calendar.textColor,
|
||||
calendar: calendar.entity_id,
|
||||
eventData: normalizedEventData,
|
||||
};
|
||||
|
||||
+50
-33
@@ -1553,7 +1553,8 @@ export const formatConsumptionShort = (
|
||||
hass: HomeAssistant,
|
||||
consumption: number | null,
|
||||
unit: string,
|
||||
targetUnit?: string
|
||||
targetUnit?: string,
|
||||
displayPrecision?: number
|
||||
): string => {
|
||||
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
|
||||
let pickedUnit = unit;
|
||||
@@ -1583,10 +1584,19 @@ export const formatConsumptionShort = (
|
||||
pickedUnit = units[unitIndex];
|
||||
}
|
||||
return (
|
||||
formatNumber(val, hass.locale, {
|
||||
maximumFractionDigits:
|
||||
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
|
||||
}) +
|
||||
formatNumber(
|
||||
val,
|
||||
hass.locale,
|
||||
displayPrecision !== undefined && pickedUnit === unit
|
||||
? {
|
||||
minimumFractionDigits: displayPrecision,
|
||||
maximumFractionDigits: displayPrecision,
|
||||
}
|
||||
: {
|
||||
maximumFractionDigits:
|
||||
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
|
||||
}
|
||||
) +
|
||||
" " +
|
||||
pickedUnit
|
||||
);
|
||||
@@ -1909,20 +1919,13 @@ export const downloadEnergyData = (
|
||||
const device_consumption_water = energyData.prefs.device_consumption_water;
|
||||
const stats = energyData.state.stats;
|
||||
|
||||
const timeSet = new Set<number>();
|
||||
Object.values(stats).forEach((stat) => {
|
||||
stat.forEach((datapoint) => {
|
||||
timeSet.add(datapoint.start);
|
||||
});
|
||||
});
|
||||
const times = Array.from(timeSet).sort();
|
||||
|
||||
const headers =
|
||||
"entity_id,type,unit," +
|
||||
times.map((t) => new Date(t).toISOString()).join(",") +
|
||||
"\n";
|
||||
const csv: string[] = [];
|
||||
csv[0] = headers;
|
||||
interface CsvRow {
|
||||
id: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
data: StatisticValue[];
|
||||
}
|
||||
const rows: CsvRow[] = [];
|
||||
|
||||
const processCsvRow = function (
|
||||
id: string,
|
||||
@@ -1930,20 +1933,7 @@ export const downloadEnergyData = (
|
||||
unit: string,
|
||||
data: StatisticValue[]
|
||||
) {
|
||||
let n = 0;
|
||||
const row: string[] = [];
|
||||
row.push(id);
|
||||
row.push(type);
|
||||
row.push(unit.normalize("NFKD"));
|
||||
times.forEach((t) => {
|
||||
if (n < data.length && data[n].start === t) {
|
||||
row.push((data[n].change ?? "").toString());
|
||||
n++;
|
||||
} else {
|
||||
row.push("");
|
||||
}
|
||||
});
|
||||
csv.push(row.join(",") + "\n");
|
||||
rows.push({ id, type, unit, data });
|
||||
};
|
||||
|
||||
const processStat = function (stat: string, type: string, unit: string) {
|
||||
@@ -2190,6 +2180,33 @@ export const downloadEnergyData = (
|
||||
);
|
||||
}
|
||||
|
||||
const timeSet = new Set<number>();
|
||||
rows.forEach((row) => {
|
||||
row.data.forEach((datapoint) => {
|
||||
timeSet.add(datapoint.start);
|
||||
});
|
||||
});
|
||||
const times = Array.from(timeSet).sort();
|
||||
|
||||
const csv: string[] = [
|
||||
"entity_id,type,unit," +
|
||||
times.map((t) => new Date(t).toISOString()).join(",") +
|
||||
"\n",
|
||||
];
|
||||
rows.forEach(({ id, type, unit, data }) => {
|
||||
let n = 0;
|
||||
const row: string[] = [id, type, unit.normalize("NFKD")];
|
||||
times.forEach((t) => {
|
||||
if (n < data.length && data[n].start === t) {
|
||||
row.push((data[n].change ?? "").toString());
|
||||
n++;
|
||||
} else {
|
||||
row.push("");
|
||||
}
|
||||
});
|
||||
csv.push(row.join(",") + "\n");
|
||||
});
|
||||
|
||||
const blob = new Blob(csv, {
|
||||
type: "text/csv",
|
||||
});
|
||||
|
||||
@@ -96,10 +96,17 @@ export interface ValveEntityOptions {
|
||||
favorite_positions?: number[];
|
||||
}
|
||||
|
||||
export type FavoriteOption =
|
||||
"favorite_colors" | "favorite_positions" | "favorite_tilt_positions";
|
||||
export interface TimerEntityOptions {
|
||||
presets?: number[];
|
||||
}
|
||||
|
||||
export type FavoritesDomain = "light" | "cover" | "valve";
|
||||
export type FavoriteOption =
|
||||
| "favorite_colors"
|
||||
| "favorite_positions"
|
||||
| "favorite_tilt_positions"
|
||||
| "presets";
|
||||
|
||||
export type FavoritesDomain = "light" | "cover" | "valve" | "timer";
|
||||
|
||||
export type FavoriteOptionValue = LightColor[] | number[];
|
||||
|
||||
@@ -107,6 +114,7 @@ export const DOMAINS_WITH_FAVORITES: FavoritesDomain[] = [
|
||||
"light",
|
||||
"cover",
|
||||
"valve",
|
||||
"timer",
|
||||
];
|
||||
|
||||
export const isFavoritesDomain = (domain: string): domain is FavoritesDomain =>
|
||||
@@ -173,6 +181,7 @@ export interface EntityRegistryOptions {
|
||||
light?: LightEntityOptions;
|
||||
cover?: CoverEntityOptions;
|
||||
valve?: ValveEntityOptions;
|
||||
timer?: TimerEntityOptions;
|
||||
vacuum?: VacuumEntityOptions;
|
||||
device_tracker?: DeviceTrackerEntityOptions;
|
||||
switch_as_x?: SwitchAsXEntityOptions;
|
||||
@@ -200,6 +209,7 @@ export interface EntityRegistryEntryUpdateParams {
|
||||
| LightEntityOptions
|
||||
| CoverEntityOptions
|
||||
| ValveEntityOptions
|
||||
| TimerEntityOptions
|
||||
| VacuumEntityOptions
|
||||
| DeviceTrackerEntityOptions;
|
||||
aliases?: (string | null)[];
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
|
||||
export const NO_STATE_CLASS = "none";
|
||||
|
||||
export const computeStateClassName = (
|
||||
localize: LocalizeFunc,
|
||||
stateClass: string
|
||||
): string =>
|
||||
localize(
|
||||
`component.sensor.entity_component._.state_attributes.state_class.state.${stateClass}`
|
||||
) || stateClass;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user