Compare commits

..
39 Commits
Author SHA1 Message Date
Bram Kragten 18f79dfc91 Bumped version to 20260826.6 2026-09-04 12:40:08 +02:00
Paul BotteinandBram Kragten 5861c7487c Only select ha-input-copy text on click when readonly (#53959) 2026-09-04 12:39:01 +02:00
WendelinandBram Kragten 9dbfa821d3 History: Fix "add targets" on empty state (#53953)
Fix history empty targets button only on closed filterbar
2026-09-04 12:38:59 +02:00
Bram KragtenandCopilot Autofix powered by AI 1821e8fd67 Check if failed import is from us, or from user, and check if chunck 404's (#53940)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:38:58 +02:00
Paul BotteinandBram Kragten c8a3aa1d9a Hide empty labels row in more-info related view (#53939) 2026-09-04 12:38:57 +02:00
6c606a4968 Fix websocket hang in picture card (#53930)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:38:56 +02:00
f59a4b4996 Clarify what uninstalling an app deletes (#53923)
* Clarify what uninstalling an app deletes

The uninstall dialog offered a single switch labeled "Also remove app
data". That named the wrong thing twice over: the switch sends
remove_config, which Supervisor applies to the app's configuration
folder, while the app's data folder is deleted on uninstall either way,
independent of the switch.

Relabel the switch after the folder it actually removes, and state in the
dialog that uninstalling permanently deletes the app's stored data, so
the unconditional part of the operation is no longer implied to be
optional.

Rename the local variable and the uninstallHassioAddon parameter to
removeConfig to match the API field they carry.

* Move the app name out of the uninstall dialog title

Dialog guidelines ask to keep user generated content out of titles, as
names can get long enough to be unreadable there. The name now carries
the sentence in the body, which already describes what is deleted.

* Scope the uninstall warning to the app's private data folder

Uninstalling deletes the app's data folder and, when the app maps it,
the public config folder. Files the app wrote through the share, media
or Home Assistant config mappings stay where they are, so promising
that all of its data goes away is wrong for those apps.

Name the folder the deletion is limited to, and say the configuration
folder is only deleted if the app uses one, matching how the Supervisor
API documents the field. Also switch the label to "delete" for
consistency with the sentence above it.

* Name what an app actually keeps in its data folder

Checked the 30 most installed apps for what they write there. It holds
databases (MariaDB, InfluxDB, Grafana, UniFi, Nginx Proxy Manager,
AdGuard), credentials and network identity (Matter fabrics, the Thread
network key, Tailscale node state, Z-Wave JS settings, Mosquitto
accounts) and internal state such as caches and SSH host keys.

"Any other files it created" reaches past all of that. ESPHome keeps
device configuration in the Home Assistant config folder, Node-RED sets
its user directory to /config, WireGuard writes its keys to /ssl, and
those survive an uninstall.

* Fix punctuation in uninstall dialog text

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:38:55 +02:00
Bram Kragten 4c08607dfe Bumped version to 20260826.4 2026-09-02 12:32:22 +02:00
f082816d9c Group the connectivity panels behind a Connectivity settings page (#53905)
* Group the connectivity panels behind a Connectivity settings page

Move the connectivity panels (Matter, Zigbee, Z-Wave, KNX, MQTT, Thread,
Bluetooth, serial, infrared, radio frequency, Insteon, and tags) behind a new
Connectivity page. It sits with Voice assistants in the second settings group,
where those panels used to be listed individually.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y7Yy1BcbkMjCZEtkTEqyKm

* Update config panel e2e coverage for the Connectivity page

The connectivity panels moved off the settings root, so split the link smoke
cases and assert them on the Connectivity page instead. Add a route smoke case
for /config/connectivity.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y7Yy1BcbkMjCZEtkTEqyKm

---------

Co-authored-by: Claude <[email protected]>
2026-09-02 12:30:56 +02:00
Bram Kragten b503862853 Bumped version to 20260826.3 2026-09-02 10:50:44 +02:00
Bram Kragten 1afa83d244 Fix stale trigger/condition lists (#53932) 2026-09-02 10:50:23 +02:00
Petar PetrovandBram Kragten 7b93dde3c6 Only pan sankey charts on touch devices once zoomed (#53931) 2026-09-02 10:50:22 +02:00
Paul BotteinandBram Kragten f8cb27b7ab Migrate add-to action list to grouped list style (#53921) 2026-09-02 10:50:21 +02:00
Paul BotteinandBram Kragten 62f1509be4 Split more-info related and details views (#53920)
* Split more-info related and details views

* Update the related view e2e smoke case
2026-09-02 10:50:20 +02:00
Bram Kragten efcf93191f Bumped version to 20260826.2 2026-09-01 10:34:13 +02:00
Maarten LakerveldandBram Kragten 577ededaf4 Fix log entry details collapsing to an ellipsis on narrow screens (#53881)
The secondary line of system log entries was made inline-block in #53575
to apply text direction. An inline-block is an atomic inline, so the
parent's text-overflow: ellipsis hid the whole line whenever it did not
fit, leaving only "…" on mobile. Use unicode-bidi: isolate instead so
direction still applies while the text truncates normally.

Fixes #53879
2026-09-01 10:33:46 +02:00
Paul BotteinandBram Kragten 1cd16ad301 Always allow saving vacuum segment mapping (#53862) 2026-09-01 10:33:44 +02:00
Paul BotteinandBram Kragten 2c2f733a82 Remove vacuum battery_level attribute unit (#53855) 2026-09-01 10:33:43 +02:00
Krisjanis LejejsandBram Kragten 22df9c704c Use the cloud auto-login flow in the voice satellite wizard (#53854)
Replace the wizard's own registration and sign-in screens with the cloud
panel's, extracted into cloud-register-card, so signing up mid-wizard gets
email-confirmation auto login instead of the legacy register plus login
polling. The wizard's copy of the login error ladder goes with it.

Drive the registration state from cloud/status rather than from the cloud
events, which carry nothing it does not already hold. The waiting view now
clears when the pending registration does, so a restart no longer leaves it
spinning on a registration the backend has forgotten.

Depends on the auto-login state fix in home-assistant/core#180423: without
it a retry loop that gives up before the register view stores its controller
leaves cloud/status reporting a pending registration with no reason, which
this no longer reads from the event instead.
2026-09-01 10:33:42 +02:00
Maarten LakerveldandBram Kragten d73341b2f8 Allow negative number entry on iOS when the number selector has no min (#53852)
The iOS workaround from #52925 only applied when the selector had an
explicit negative min. The numeric threshold selector used by the power
triggers passes a number selector without a min, so the digit-only keypad
(without a minus key) was still shown. Treat a missing min as allowing
negatives too.

Also leave inputmode unset instead of forcing "text": on a number input
iOS then shows the Numbers and Punctuation keyboard, which has a minus
key and fits numeric entry better than the full QWERTY keyboard.

Fixes #53747
2026-09-01 10:33:41 +02:00
Bram Kragten 907d15e7c1 Load the base map through core's tile proxy (#53845)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
(cherry picked from commit a9d7712fb2)
2026-09-01 10:32:08 +02:00
Maarten LakerveldandBram Kragten 662ac1dc57 Fix RTL map labels and add English names to non-Latin ones (#53856)
* Load MapLibre RTL text plugin so Hebrew and Arabic map labels read correctly

The vector base map never registered MapLibre's RTL text plugin, so
right-to-left scripts were shaped left to right and every label came out
reversed. Ship @mapbox/mapbox-gl-rtl-text from /static/map/ alongside the
glyphs (no third-party host) and register it once, lazily, before the
first vector layer is created.

Fixes #53851

* Add the English name to map labels in non-Latin scripts

Before the move to vector tiles the CARTO basemap showed English names
everywhere. The OSM style shows local names, which most users cannot
read in Cyrillic, Arabic, Hebrew or CJK regions. Keep the local name and
add the English one from the tiles under it - in parentheses for street
labels, which cannot break lines. Latin-script names are left alone, so
Köln stays Köln.

(cherry picked from commit 156840b6ff)
2026-09-01 10:32:02 +02:00
Paul BotteinandBram Kragten 39e7a05258 Fix the History timeline labels and tooltip placement (#53843)
* Draw History timeline names in a label column again

* Keep the timeline tooltip level with the hovered row
2026-09-01 10:27:21 +02:00
Paul BotteinandBram Kragten 5cb7de5ac6 Fix weather forecast tabs in the more info dialog (#53841) 2026-09-01 10:27:20 +02:00
Paulus SchoutsenandBram Kragten 0c7d3c8483 Render camera favorites as camera tiles (#53814) 2026-09-01 10:27:18 +02:00
lalexdotcomandBram Kragten c92e5aa134 Report the fallback camera preview as loaded in ha-camera-stream (#53810)
`ha-camera-stream` declares a `load` event and both of its video players fire it through `fireEvent(this, "load")`, but the MJPEG and snapshot fallback `<img>` never does. Every camera without a working stream therefore goes through the one path that stays silent.

`hui-image` listens for that event to record `_lastImageHeight`, and that value is what drops the `.ratio` class so the container can size to its content. Without it the container keeps the 16:9 padding box it falls back to before anything is measured, while the picture is drawn at its own ratio and overflows. Measured against a 600x410 camera in a 600px wide card: the container stays 337.5px tall while the picture is drawn 410px tall, and it is still so ten seconds after the card is built.

Firing the event from the fallback image settles it: the container becomes 410px, matching the picture, and the 16:9 fallback goes back to being a placeholder for the first frame instead of a permanent state. Cameras that do have a stream are unaffected — they already fire `load` from their player.
2026-09-01 10:27:17 +02:00
Bram Kragten 8f73718a35 Bumped version to 20260826.1 2026-08-27 17:07:22 +02:00
Paul BotteinandBram Kragten 4bf2acdb43 Show serial port info in a bottom sheet on mobile (#53839) 2026-08-27 17:06:48 +02:00
Shay RedmondandBram Kragten f622e5eeb9 Small copy improvements for Cloud feature list (#53833) 2026-08-27 17:06:47 +02:00
Paul BotteinandBram Kragten 595068597f Keep the row config when changing a device automation type (#53832) 2026-08-27 17:06:46 +02:00
Aidan TimsonandBram Kragten c708f35ab6 Fix MQTT subscribe controls layout (#53831) 2026-08-27 17:06:45 +02:00
Aidan TimsonandBram Kragten 205874c800 Show app icons for serial port consumers (#53827) 2026-08-27 17:06:44 +02:00
Petar Petrov 47ddcbe2e5 Fix editor state leaking when switching automations, scripts, or scenes (#53826)
* Fix editor state leaking when switching automations, scripts, or scenes

Recreate the page when the item id in the route changes so YAML mode,
undo history, and scene live state cannot carry over to the next item.

* Declare itemId on editor routes instead of guessing from the path

Path-shape matching remounted nested config routers, Lovelace, and
developer tools. Recreate only where edit/show routes opt in.

* Do not restore scene live states when the editor is torn down

A remounted editor starts in review; disconnect only needs to drop
the subscription, not undo device changes from live mode.

* Use the normal create path when remounting itemId pages

Clear the current page instead of calling _createPanel directly so
load and the loading screen still run. Drop the redundant cache delete.

* Fix dialog-form tests leaking focus restore after jsdom teardown

Nested submit/cancel schedules a nextRender callback that ran after
Vitest tore down HTMLElement, failing CI with an unhandled rejection.
2026-08-27 17:06:43 +02:00
Petar PetrovandBram Kragten c43b8bfbe0 Do not show empty Grid legend item on energy usage graph (#53822)
Do not add empty combined Grid series on energy usage graph
2026-08-27 17:06:41 +02:00
Bram KragtenandClaude Opus 5 e349b00b15 Replace CARTO raster tiles with OpenStreetMap vector tiles (#53816)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-27 17:06:40 +02:00
Paul BotteinandBram Kragten 0bb8bdc98a Clarify the replaced device state in pickers and target rows (#53807) 2026-08-27 17:06:39 +02:00
Petar PetrovandBram Kragten bbc5385843 Fix more-info dialog rewriting the URL after navigation (#53806) 2026-08-27 17:06:37 +02:00
Paul BotteinandBram Kragten 43dc5651d3 Keep the automation config when replacing a device in device automations (#53805) 2026-08-27 17:06:36 +02:00
Aidan TimsonandBram Kragten 18dcf7abc0 Refine entity details context section (#53797) 2026-08-27 17:06:35 +02:00
291 changed files with 3395 additions and 16710 deletions
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:javascript-typescript"
+1 -1
View File
@@ -1 +1 @@
24.20.0
24.19.0
+1 -9
View File
@@ -34,14 +34,6 @@ In production, the following responsibilities are added:
- Minify HTML
- Bundle multiple imports so that the browser can fetch less files
- Generate a second version that is compatible with older browsers (legacy build)
- Generate a second version that is ES5 compatible
Configuration for all these steps are specified in [bundle.js](bundle.js).
## Auditing browser support changes
`node build-scripts/list-plugins-and-polyfills.js` prints, per browserslist
environment (modern/legacy), the Babel transforms preset-env enables and the
Core-JS polyfills that may be injected — as collapsible markdown ready to
paste into a PR. Use it to show the bundle impact when changing
`.browserslistrc` or the Babel configuration.
+3 -1
View File
@@ -121,7 +121,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
ignoreModuleNotFound: true,
},
],
// Import helpers from runtime package.
// Import helpers and regenerator from runtime package.
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
// corejs3 polyfill provider above otherwise redirects them to the
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
@@ -155,6 +155,8 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
"@lit-labs/virtualizer/polyfills",
"@webcomponents/scoped-custom-element-registry",
"element-internals-polyfill",
"proxy-polyfill",
"unfetch",
].map((p) => new RegExp(`/node_modules/${p}/`)),
],
},
+2 -2
View File
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
16: [11, 0, 0],
17: [12, 0, 0],
18: [13, 0, 0],
26: [14, 6, 0],
26: [26, 0, 0],
};
const getCommonTemplateVars = () => {
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
...htmlMinifierOptions,
conservativeCollapse: false,
minifyJS: terserOptions({
latestBuild: false, // Shared scripts must satisfy the legacy targets
latestBuild: false, // Shared scripts should be ES5
isTestBuild: true, // Don't need source maps
}),
}).then((wrapped) =>
+35 -2
View File
@@ -42,6 +42,37 @@ function copyMdiIcons(staticDir) {
fs.copySync(polyPath("build/mdi"), staticPath("mdi"));
}
function copyPolyfills(staticDir) {
const staticPath = genStaticPath(staticDir);
// For custom panels using ES5 builds that don't use Babel 7+
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"),
staticPath("polyfills/")
);
// Web Component polyfills and adapters
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js"),
staticPath("polyfills/")
);
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js.map"),
staticPath("polyfills/")
);
// Lit polyfill support
fs.copySync(
npmPath("lit/polyfill-support.js"),
path.join(staticPath("polyfills/"), "lit-polyfill-support.js")
);
// dialog-polyfill css
copyFileDir(
npmPath("dialog-polyfill/dialog-polyfill.css"),
staticPath("polyfills/")
);
}
function copyFonts(staticDir) {
const staticPath = genStaticPath(staticDir);
// Local fonts
@@ -81,8 +112,6 @@ async function copyMapPanel(staticDir) {
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
function copyZXingWasm(staticDir) {
@@ -112,6 +141,7 @@ 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);
@@ -133,6 +163,7 @@ gulp.task("copy-static-demo", async () => {
);
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
copyPolyfills(paths.demo_output_static);
await copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
@@ -145,6 +176,7 @@ gulp.task("copy-static-cast", async () => {
fs.copySync(polyPath("public/static"), paths.cast_output_static);
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
copyPolyfills(paths.cast_output_static);
await copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
@@ -191,6 +223,7 @@ 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 ?? [];
const stateClasses = [...(data.state_classes ?? [])].sort();
const stateClassUnits = data.state_class_units ?? {};
if (
!numericDeviceClasses.length ||
+27 -62
View File
@@ -1,55 +1,38 @@
#!/usr/bin/env node
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
import { transformSync } from "@babel/core";
import compilationTargets, {
getInclusionReasons,
} from "@babel/helper-compilation-targets";
import { version as babelVersion } from "@babel/core";
import presetEnv from "@babel/preset-env";
import compilationTargets from "@babel/helper-compilation-targets";
import coreJSCompat from "core-js-compat";
import { logPlugin } from "@babel/preset-env/lib/debug.js";
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
import { babelOptions } from "./bundle.cjs";
const detailsOpen = (heading) =>
`<details>\n<summary><h4>${heading}</h4></summary>\n`;
const detailsClose = "</details>\n";
// Copied from @babel/preset-env's internal `logPlugin`, which Babel 8 no
// longer exposes (the package rolls up into lib/index.js and exports nothing
// but the preset). Prints an item with the targets that require it.
const logPlugin = (item, targetVersions, list) => {
const filteredList = getInclusionReasons(item, targetVersions, list);
const support = list[item];
if (!support) {
console.log(` ${item}`);
return;
}
let formattedTargets = `{`;
let first = true;
for (const target of Object.keys(filteredList)) {
if (!first) formattedTargets += `,`;
first = false;
formattedTargets += ` ${target}`;
if (support[target]) formattedTargets += ` < ${support[target]}`;
}
formattedTargets += ` }`;
console.log(` ${item} ${formattedTargets}`);
const dummyAPI = {
version: babelVersion,
// eslint-disable-next-line @typescript-eslint/no-empty-function
assertVersion: () => {},
caller: (callback) =>
callback({
name: "Dummy Bundler",
supportsStaticESM: true,
supportsDynamicImport: true,
supportsTopLevelAwait: true,
supportsExportNamespaceFrom: true,
}),
targets: () => ({}),
};
// Copied from babel-plugin-polyfill-corejs3's generated
// corejs3ShippedProposalsList, which v1 no longer exposes (it is inlined in
// the package's rolled-up bundle).
const shippedProposalsList = new Set([
"esnext.array.group",
"esnext.array.group-to-map",
"esnext.iterator.zip",
"esnext.iterator.zip-keyed",
"esnext.symbol.metadata",
]);
// Generate filter function based on proposal/method inputs
// Copied and adapted from babel-plugin-polyfill-corejs3/esm/index.mjs
const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
if (proposals || method === "entry-global") return true;
if (shippedProposals && shippedProposalsList.has(name)) {
if (shippedProposals && shippedPolyfills.default.has(name)) {
return true;
}
if (name.startsWith("esnext.")) {
@@ -64,9 +47,7 @@ const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
for (const buildType of ["Modern", "Legacy"]) {
const browserslistEnv = buildType.toLowerCase();
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
const presetEnvOpts = babelOpts.presets.find(
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
)?.[1];
const presetEnvOpts = babelOpts.presets[0][1];
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
// removed preset-env's `useBuiltIns`), so read its options here.
const corejsOpts = babelOpts.plugins.find(
@@ -74,38 +55,22 @@ for (const buildType of ["Modern", "Legacy"]) {
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
)?.[1];
// Transforming an empty file with preset-env in debug mode logs the included
// plugins. The caller declares the same capabilities babel-loader does, so
// plugins gated on bundler support (e.g. transform-export-namespace-from)
// match the build.
presetEnvOpts.debug = true;
// Invoking preset-env in debug mode will log the included plugins
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
transformSync("", {
...babelOpts,
configFile: false,
filename: "audit.js",
caller: {
name: "list-plugins-and-polyfills",
supportsStaticESM: true,
supportsDynamicImport: true,
supportsTopLevelAwait: true,
supportsExportNamespaceFrom: true,
},
presetEnv.default(dummyAPI, {
...presetEnvOpts,
browserslistEnv,
debug: true,
});
console.log(detailsClose);
// Manually log the Core-JS polyfills using the same technique
if (corejsOpts) {
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
const targets = compilationTargets(babelOpts.targets, {
const targets = compilationTargets.default(babelOpts?.targets, {
browserslistEnv,
});
// `version` limits the list to modules the installed core-js ships,
// mirroring the provider's own filtering.
const polyfillList = coreJSCompat({
targets,
version: corejsOpts.version,
}).list.filter(
const polyfillList = coreJSCompat({ targets }).list.filter(
polyfillFilter(
corejsOpts.method,
corejsOpts.proposals,
+4 -1
View File
@@ -345,7 +345,10 @@ const createRspackConfig = ({
"lit/directives/join$": "lit/directives/join.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/directives/live$": "lit/directives/live.js",
"lit/directives/keyed$": "lit/directives/keyed.js",
"lit/directives/keyed$": latestBuild
? "lit/directives/keyed.js"
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
"lit/polyfill-support$": "lit/polyfill-support.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
+3 -1
View File
@@ -15,6 +15,7 @@ import {
saveTokens,
} from "../../../../src/common/auth/token_storage";
import { atLeastVersion } from "../../../../src/common/config/version";
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
@@ -196,7 +197,8 @@ class HcCast extends LitElement {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this.toggleAttribute(
toggleAttribute(
this,
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
-4
View File
@@ -3,7 +3,6 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_registry";
import { energyEntities } from "../stubs/entities";
import { connectivityEntities } from "../stubs/connectivity/fixtures";
import { setDemoFloors } from "../stubs/floor_registry";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
@@ -54,9 +53,6 @@ export const setDemoConfig = async (
setDemoAreas(hass, config.areas);
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
// Replaced the whole state map above, so the entities that do not belong to a
// demo config have to be added back.
hass.addEntities(connectivityEntities());
// Let the new registries and entities reach the dashboard before saving the
// config, so dashboard strategies generate against them
+1 -11
View File
@@ -7,12 +7,6 @@ import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import {
connectivityCommands,
connectivityComponents,
connectivityEntities,
connectivityEntityRegistryEntries,
} from "./stubs/connectivity/fixtures";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
@@ -66,7 +60,6 @@ const CONFIG_PANEL_COMMANDS = [
"assist_pipeline/",
"config/entity_registry/settings/",
"slugify",
...connectivityCommands,
];
@customElement("ha-demo")
@@ -94,7 +87,6 @@ export class HaDemo extends HomeAssistantAppEl {
"assist_pipeline",
"hassio",
"hardware",
...connectivityComponents,
],
},
});
@@ -107,7 +99,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass, localizePromise);
mockTranslations(hass);
mockHistory(hass);
mockRecorder(hass);
mockTodo(hass);
@@ -180,11 +172,9 @@ export class HaDemo extends HomeAssistantAppEl {
created_at: 0,
modified_at: 0,
},
...connectivityEntityRegistryEntries,
]);
hass.addEntities(energyEntities());
hass.addEntities(connectivityEntities());
// Once config is loaded AND localize, set registries, entities and theme.
Promise.all([selectedDemoConfig, localizePromise]).then(
-8
View File
@@ -7,7 +7,6 @@ import { mockBlueprint } from "./blueprint";
import { mockCloud } from "./cloud";
import { mockConfig } from "./config";
import { mockConfigEntries } from "./config_entries";
import { mockConnectivity } from "./connectivity";
import { mockDeviceAutomation } from "./device_automation";
import { mockEntityRegistrySettings } from "./entity_registry_settings";
import { mockEntitySources } from "./entity_sources";
@@ -27,7 +26,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockCloud(hass);
mockConfig(hass);
mockConfigEntries(hass);
mockConnectivity(hass);
mockDeviceAutomation(hass);
mockEntitySources(hass);
mockBlueprint(hass);
@@ -45,10 +43,4 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockAssist(hass);
mockEntityRegistrySettings(hass);
mockSlugify(hass);
hass.mockWS("llm/api/list", () => ({
apis: [
{ id: "assist", name: "Assist" },
{ id: "music_assistant", name: "Music Assistant" },
],
}));
};
-12
View File
@@ -5,7 +5,6 @@ import type {
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
import type { IntegrationType } from "../../../src/data/integration";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { connectivityConfigEntries } from "./connectivity/fixtures";
const baseEntry = {
source: "user",
@@ -81,17 +80,6 @@ export const demoConfigEntries: {
title: "Comfort level",
},
},
{
type: "service",
entry: {
...baseEntry,
entry_id: "mock-mcp-server",
domain: "mcp_server",
title: "Assist, Music Assistant",
supports_options: true,
},
},
...connectivityConfigEntries,
];
const filterEntries = (filters?: {
@@ -1,71 +0,0 @@
import { manifest } from "../../manifest";
import { configEntry, device } from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const LOCAL_SOURCE = "00:1A:7D:DA:71:11";
export const PROXY_SOURCE = "E8:DB:84:A1:C2:30";
export const SHED_SOURCE = "A4:CF:12:9B:44:70";
const ADAPTER_ENTRY_ID = "mock-bluetooth";
const PROXY_LIVING_ENTRY_ID = "mock-bluetooth-proxy-living";
const PROXY_SHED_ENTRY_ID = "mock-bluetooth-proxy-shed";
export const bluetoothFixtures: ConnectivityFixtures = {
components: ["bluetooth"],
commands: ["bluetooth/"],
manifests: [manifest("bluetooth", "Bluetooth", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(
ADAPTER_ENTRY_ID,
"bluetooth",
`hci0 (${LOCAL_SOURCE})`,
{ source: "usb", supports_options: true }
),
},
{
type: "hub",
entry: configEntry(
PROXY_LIVING_ENTRY_ID,
"bluetooth",
"Living room proxy",
{ source: "esphome" }
),
},
{
type: "hub",
entry: configEntry(PROXY_SHED_ENTRY_ID, "bluetooth", "Shed proxy", {
source: "esphome",
}),
},
],
// Adapters and proxies are matched to their scanner by the bluetooth
// connection tuple, see ./mock.
devices: [
device(
"bluetooth-hci0",
"hci0",
"Home Assistant",
"Home Assistant Green",
ADAPTER_ENTRY_ID,
{ connections: [["bluetooth", LOCAL_SOURCE]] }
),
device(
"bluetooth-proxy-living",
"Living room proxy",
"Espressif",
"ESP32-C3",
PROXY_LIVING_ENTRY_ID,
{ area_id: "living_room", connections: [["bluetooth", PROXY_SOURCE]] }
),
device(
"bluetooth-proxy-shed",
"Shed proxy",
"Espressif",
"ESP32",
PROXY_SHED_ENTRY_ID,
{ connections: [["bluetooth", SHED_SOURCE]] }
),
],
};
@@ -1,193 +0,0 @@
import type {
BluetoothAllocationsData,
BluetoothDeviceData,
BluetoothScannerDetails,
BluetoothScannerState,
} from "../../../../../src/data/bluetooth";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
import { LOCAL_SOURCE, PROXY_SOURCE, SHED_SOURCE } from "./fixtures";
const SCANNERS: BluetoothScannerDetails[] = [
{
source: LOCAL_SOURCE,
connectable: true,
name: "hci0 (Home Assistant Green)",
adapter: "hci0",
scanner_type: "usb",
},
{
source: PROXY_SOURCE,
connectable: true,
name: "Living room proxy",
adapter: "esp32",
scanner_type: "remote",
},
{
source: SHED_SOURCE,
connectable: false,
name: "Shed proxy",
adapter: "esp32",
scanner_type: "remote",
},
];
const SCANNER_STATES: BluetoothScannerState[] = [
{
source: LOCAL_SOURCE,
adapter: "hci0",
current_mode: "active",
requested_mode: "active",
},
{
source: PROXY_SOURCE,
adapter: "esp32",
current_mode: "active",
requested_mode: "active",
},
{
// A proxy configured for passive scanning: it reports advertisements but
// cannot connect, matching its `connectable: false` scanner details.
source: SHED_SOURCE,
adapter: "esp32",
current_mode: "passive",
requested_mode: "passive",
},
];
const ALLOCATIONS: BluetoothAllocationsData[] = [
{
source: LOCAL_SOURCE,
slots: 5,
free: 3,
allocated: ["A4:C1:38:11:22:33", "E7:2E:00:B1:9A:1C"],
},
{
source: PROXY_SOURCE,
slots: 3,
free: 2,
allocated: ["FC:58:FA:12:34:56"],
},
];
interface DemoAdvertisement {
address: string;
name: string;
rssi: number;
source: string;
connectable?: boolean;
tx_power?: number;
manufacturer_data?: Record<number, string>;
service_data?: Record<string, string>;
service_uuids?: string[];
}
const ADVERTISEMENTS: DemoAdvertisement[] = [
{
address: "A4:C1:38:11:22:33",
name: "Govee H5075",
rssi: -58,
source: LOCAL_SOURCE,
manufacturer_data: { 60552: "000104a10b64" },
service_uuids: ["0000ec88-0000-1000-8000-00805f9b34fb"],
},
{
address: "E7:2E:00:B1:9A:1C",
name: "SwitchBot Meter",
rssi: -71,
source: LOCAL_SOURCE,
service_data: { "0000fd3d-0000-1000-8000-00805f9b34fb": "5400648c14" },
service_uuids: ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
},
{
address: "FC:58:FA:12:34:56",
name: "Xiaomi LYWSD03MMC",
rssi: -64,
source: PROXY_SOURCE,
service_data: { "0000fe95-0000-1000-8000-00805f9b34fb": "3058590e" },
},
{
address: "C4:7C:8D:6A:5B:20",
name: "Flower care",
rssi: -88,
source: SHED_SOURCE,
connectable: false,
service_uuids: ["0000fe95-0000-1000-8000-00805f9b34fb"],
},
{
address: "D0:36:9A:7F:11:80",
name: "Tile Mate",
rssi: -79,
source: PROXY_SOURCE,
connectable: false,
service_uuids: ["0000feed-0000-1000-8000-00805f9b34fb"],
},
{
address: "5C:C7:C1:04:9E:2A",
name: "Nut Find 3",
rssi: -93,
source: SHED_SOURCE,
connectable: false,
},
];
const buildAdvertisement = (
advertisement: DemoAdvertisement
): BluetoothDeviceData => ({
address: advertisement.address,
name: advertisement.name,
rssi: advertisement.rssi,
source: advertisement.source,
connectable: advertisement.connectable ?? true,
manufacturer_data: advertisement.manufacturer_data ?? {},
service_data: advertisement.service_data ?? {},
service_uuids: advertisement.service_uuids ?? [],
tx_power: advertisement.tx_power ?? -59,
time: Date.now() / 1000,
raw: null,
});
// Nudge the signal strength a little on every tick so the monitors and the
// network map look alive without the rows jumping around.
const jitter = (rssi: number) =>
Math.max(-99, Math.min(-30, rssi + Math.round(Math.random() * 4) - 2));
export const mockBluetooth = (hass: MockHomeAssistant) => {
hass.mockWS("bluetooth/subscribe_scanner_details", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.({ add: SCANNERS }))
);
hass.mockWS("bluetooth/subscribe_scanner_state", (_msg, _hass, onChange) =>
emitInitial(() => SCANNER_STATES.forEach((state) => onChange?.(state)))
);
hass.mockWS(
"bluetooth/subscribe_connection_allocations",
(msg: { config_entry_id?: string }, _hass, onChange) =>
emitInitial(() =>
onChange?.(
msg.config_entry_id
? ALLOCATIONS.filter((a) => a.source === LOCAL_SOURCE)
: ALLOCATIONS
)
)
);
hass.mockWS("bluetooth/subscribe_advertisements", (_msg, _hass, onChange) => {
let advertisements = ADVERTISEMENTS;
const stopInitial = emitInitial(() =>
onChange?.({ add: advertisements.map(buildAdvertisement) })
);
const interval = window.setInterval(() => {
advertisements = advertisements.map((advertisement) => ({
...advertisement,
rssi: jitter(advertisement.rssi),
}));
onChange?.({ change: advertisements.map(buildAdvertisement) });
}, 5000);
return () => {
stopInitial();
clearInterval(interval);
};
});
};
-60
View File
@@ -1,60 +0,0 @@
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
import { mqttFixtures } from "./mqtt/fixtures";
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
import { serialFixtures } from "./serial/fixtures";
import { tagsFixtures } from "./tags/fixtures";
import { threadFixtures } from "./thread/fixtures";
import type { ConnectivityFixtures } from "./types";
import { zhaFixtures } from "./zha/fixtures";
import { zwaveJsFixtures } from "./zwave_js/fixtures";
// Every integration reachable from Settings > Connectivity that has frontend
// data to mock. Each owns its own fixtures, so they can be added and removed
// one at a time.
const INTEGRATIONS: ConnectivityFixtures[] = [
bluetoothFixtures,
serialFixtures,
mqttFixtures,
matterFixtures,
infraredFixtures,
zwaveJsFixtures,
zhaFixtures,
radioFrequencyFixtures,
tagsFixtures,
threadFixtures,
];
const collect = <T>(
pick: (fixtures: ConnectivityFixtures) => T[] | undefined
) => INTEGRATIONS.flatMap((fixtures) => pick(fixtures) ?? []);
export const connectivityComponents = collect((f) => f.components);
export const connectivityCommands = collect((f) => f.commands);
export const connectivityConfigEntries = collect((f) => f.configEntries);
export const connectivityManifests = collect((f) => f.manifests);
export const connectivityDevices = collect((f) => f.devices);
export const connectivityEntityRegistryEntries = collect(
(f) => f.entityRegistryEntries
);
export const connectivityEntities = () =>
INTEGRATIONS.flatMap((fixtures) => fixtures.entities?.() ?? []);
/** Backend translation resources, merged per category. */
export const connectivityBackendTranslations = INTEGRATIONS.reduce<
Record<string, Record<string, string>>
>((resources, fixtures) => {
for (const [category, keys] of Object.entries(
fixtures.backendTranslations ?? {}
)) {
resources[category] = { ...resources[category], ...keys };
}
return resources;
}, {});
-129
View File
@@ -1,129 +0,0 @@
import type { ConfigEntry } from "../../../../src/data/config_entries";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
import type { EntityInput } from "../../../../src/fake_data/entities/types";
// Builders for the registry fixtures, so each integration only spells out what
// makes its own entries different.
const BASE_CONFIG_ENTRY = {
source: "user",
state: "loaded" as const,
supports_options: false,
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
num_subentries: 0,
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
};
export const configEntry = (
entryId: string,
domain: string,
title: string,
extra: Partial<ConfigEntry> = {}
): ConfigEntry => ({
...BASE_CONFIG_ENTRY,
entry_id: entryId,
domain,
title,
...extra,
});
const BASE_DEVICE = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
entry_type: null,
created_at: 0,
modified_at: 0,
};
export const device = (
id: string,
name: string,
manufacturer: string,
model: string,
entryId: string,
extra: Partial<DeviceRegistryEntry> = {}
): DeviceRegistryEntry => ({
...BASE_DEVICE,
id,
name,
manufacturer,
model,
config_entries: [entryId],
primary_config_entry: entryId,
...extra,
});
const BASE_REGISTRY_ENTRY = {
config_subentry_id: null,
area_id: null,
disabled_by: null,
icon: null,
labels: [] as string[],
categories: {},
hidden_by: null,
entity_category: null,
options: null,
created_at: 0,
modified_at: 0,
};
export const registryEntry = (
entityId: string,
deviceId: string,
entryId: string,
platform: string,
name?: string
): EntityRegistryEntry => ({
...BASE_REGISTRY_ENTRY,
entity_id: entityId,
id: entityId,
unique_id: entityId,
device_id: deviceId,
config_entry_id: entryId,
platform,
name: name ?? null,
has_entity_name: name === undefined,
});
/**
* The demo's `addEntities` builds the display entity registry from the entity
* inputs, so mirror each state's registry entry onto it. Panels count entities
* per device and per integration.
*/
export const withRegistryLinks = (
entries: EntityRegistryEntry[],
states: Record<string, EntityInput>
): EntityInput[] =>
Object.values(states).map((state) => {
const entry = entries.find(
(candidate) => candidate.entity_id === state.entity_id
);
return entry
? { ...state, device_id: entry.device_id!, platform: entry.platform }
: state;
});
export const minutesAgo = (minutes: number) =>
new Date(Date.now() - minutes * 60000).toISOString();
-25
View File
@@ -1,25 +0,0 @@
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
import { mockMqtt } from "./mqtt/mock";
import { mockRadioFrequency } from "./radio_frequency/mock";
import { mockSerial } from "./serial/mock";
import { mockThread } from "./thread/mock";
import { mockZha } from "./zha/mock";
import { mockZwaveJs } from "./zwave_js/mock";
// The WebSocket mocks, code-split into the config panel chunk.
const MOCKS = [
mockBluetooth,
mockSerial,
mockMatter,
mockMqtt,
mockZwaveJs,
mockZha,
mockRadioFrequency,
mockThread,
];
export const mockConnectivity = (hass: MockHomeAssistant) => {
MOCKS.forEach((mock) => mock(hass));
};
@@ -1,107 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
minutesAgo,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-broadlink";
const DEVICES = [
device(
"broadlink-living-room",
"Living room blaster",
"Broadlink",
"RM4 pro",
ENTRY_ID,
{ area_id: "living_room" }
),
device(
"broadlink-bedroom",
"Bedroom blaster",
"Broadlink",
"RM mini 3",
ENTRY_ID,
{ area_id: "bedroom" }
),
];
// The infrared panel is entity driven: the proxy entities live in the
// `infrared` domain while their registry platform stays the integration that
// provides them.
const REGISTRY_ENTRIES = [
registryEntry(
"infrared.living_room_blaster_emitter",
"broadlink-living-room",
ENTRY_ID,
"broadlink",
"Emitter"
),
registryEntry(
"infrared.living_room_blaster_receiver",
"broadlink-living-room",
ENTRY_ID,
"broadlink",
"Receiver"
),
registryEntry(
"infrared.bedroom_blaster_emitter",
"broadlink-bedroom",
ENTRY_ID,
"broadlink",
"Emitter"
),
];
export const infraredFixtures: ConnectivityFixtures = {
components: ["infrared"],
manifests: [
manifest("broadlink", "Broadlink", {
integration_type: "hub",
iot_class: "local_polling",
}),
],
configEntries: [
{ type: "hub", entry: configEntry(ENTRY_ID, "broadlink", "RM4 pro") },
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
// A proxy entity's state is the timestamp it was last used.
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"infrared.living_room_blaster_emitter": {
entity_id: "infrared.living_room_blaster_emitter",
state: minutesAgo(12),
attributes: {
friendly_name: "Living room blaster Emitter",
device_class: "emitter",
},
},
"infrared.living_room_blaster_receiver": {
entity_id: "infrared.living_room_blaster_receiver",
state: minutesAgo(3),
attributes: {
friendly_name: "Living room blaster Receiver",
device_class: "receiver",
},
},
"infrared.bedroom_blaster_emitter": {
entity_id: "infrared.bedroom_blaster_emitter",
state: minutesAgo(1440),
attributes: {
friendly_name: "Bedroom blaster Emitter",
device_class: "emitter",
},
},
}),
backendTranslations: {
entity_component: {
// The emitter is the default device class, stored under the "_" key.
"component.infrared.entity_component._.name": "Emitter",
"component.infrared.entity_component.receiver.name": "Receiver",
},
},
};
@@ -1,119 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-matter";
const DEVICES = [
device(
"matter-kitchen-light",
"Kitchen ceiling",
"Nanoleaf",
"Essentials A19",
ENTRY_ID,
{ area_id: "kitchen", sw_version: "3.5.7" }
),
device(
"matter-side-door-lock",
"Side door lock",
"Aqara",
"Smart Lock U100",
ENTRY_ID,
{ sw_version: "1.2.0" }
),
device("matter-office-plug", "Office plug", "Eve", "Energy", ENTRY_ID, {
area_id: "office",
sw_version: "3.2.0",
}),
device("matter-patio-sensor", "Patio sensor", "Eve", "Weather", ENTRY_ID, {
area_id: "garden",
sw_version: "3.2.1",
}),
];
const REGISTRY_ENTRIES = [
registryEntry(
"light.kitchen_ceiling",
"matter-kitchen-light",
ENTRY_ID,
"matter"
),
registryEntry("lock.side_door", "matter-side-door-lock", ENTRY_ID, "matter"),
registryEntry("switch.office_plug", "matter-office-plug", ENTRY_ID, "matter"),
registryEntry(
"sensor.office_plug_power",
"matter-office-plug",
ENTRY_ID,
"matter"
),
registryEntry(
"sensor.patio_temperature",
"matter-patio-sensor",
ENTRY_ID,
"matter"
),
];
export const matterFixtures: ConnectivityFixtures = {
components: ["matter"],
commands: ["matter/"],
manifests: [manifest("matter", "Matter", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "matter", "Matter", {
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"light.kitchen_ceiling": {
entity_id: "light.kitchen_ceiling",
state: "on",
attributes: {
friendly_name: "Kitchen ceiling",
supported_color_modes: ["color_temp"],
color_mode: "color_temp",
brightness: 204,
},
},
"lock.side_door": {
entity_id: "lock.side_door",
state: "locked",
attributes: { friendly_name: "Side door lock" },
},
"switch.office_plug": {
entity_id: "switch.office_plug",
state: "on",
attributes: { friendly_name: "Office plug" },
},
"sensor.office_plug_power": {
entity_id: "sensor.office_plug_power",
state: "42.5",
attributes: {
friendly_name: "Office plug power",
device_class: "power",
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.patio_temperature": {
entity_id: "sensor.patio_temperature",
state: "14.2",
attributes: {
friendly_name: "Patio temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
}),
};
-404
View File
@@ -1,404 +0,0 @@
import type {
MatterCommissioningParameters,
MatterFabricData,
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
MatterNodeDiagnostics,
} from "../../../../../src/data/matter";
import { NetworkType, NodeType } from "../../../../../src/data/matter";
import type {
MatterLockInfo,
MatterLockUser,
MatterLockUsersResponse,
SetMatterLockCredentialResult,
} from "../../../../../src/data/matter-lock";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
const EXT_PAN_ID = "dead00beef00cafe";
const THREAD_NETWORK = "ha-thread";
const NODES: MatterNetworkTopologyNode[] = [
{
id: "otbr",
kind: "border_router",
network_type: "thread",
ha_device_id: null,
role: "leader",
available: true,
ext_address: "f6a1c30d2b4e5f61",
rloc16: 0x4000,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
host_name: "homeassistant",
vendor_name: "Home Assistant",
model_name: "OpenThread Border Router",
},
{
id: "wifi-ap",
kind: "wifi_ap",
network_type: "wifi",
ha_device_id: null,
available: true,
ssid: "Home",
bssid: "3c:37:86:11:22:33",
vendor_name: "Ubiquiti",
model_name: "U6 Pro",
},
{
id: "node-1",
kind: "matter",
network_type: "thread",
node_id: 1,
ha_device_id: "matter-kitchen-light",
available: true,
role: "router",
ext_address: "10a2b3c4d5e6f708",
rloc16: 0x8401,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
vendor_name: "Nanoleaf",
model_name: "Essentials A19",
},
{
id: "node-2",
kind: "matter",
network_type: "thread",
node_id: 2,
ha_device_id: "matter-side-door-lock",
available: true,
role: "sleepy_end_device",
ext_address: "20b3c4d5e6f70819",
rloc16: 0x8402,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
vendor_name: "Aqara",
model_name: "Smart Lock U100",
},
{
id: "node-3",
kind: "matter",
network_type: "wifi",
node_id: 3,
ha_device_id: "matter-office-plug",
available: true,
ssid: "Home",
vendor_name: "Eve",
model_name: "Energy",
},
{
id: "node-4",
kind: "matter",
network_type: "thread",
node_id: 4,
ha_device_id: "matter-patio-sensor",
available: false,
role: "end_device",
ext_address: "30c4d5e6f708192a",
rloc16: 0x8403,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
vendor_name: "Eve",
model_name: "Weather",
},
{
id: "thread-unknown-1",
kind: "thread_unknown",
network_type: "thread",
available: true,
role: "end_device",
ext_address: "40d5e6f708192a3b",
rloc16: 0x8404,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
},
];
const connection = (
source: string,
target: string,
network: string,
strength: MatterNetworkTopologyConnection["strength"],
lqi?: number,
rssi?: number
): MatterNetworkTopologyConnection => ({
source,
target,
network,
strength,
source_to_target: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
target_to_source: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
via_route_table: false,
path_cost: null,
});
const CONNECTIONS: MatterNetworkTopologyConnection[] = [
connection("otbr", "node-1", "thread", "strong", 245, -42),
connection("otbr", "node-2", "thread", "medium", 160, -68),
connection("node-1", "node-4", "thread", "weak", 84, -86),
connection("node-1", "thread-unknown-1", "thread", "medium", 172, -63),
connection("wifi-ap", "node-3", "wifi", "strong", undefined, -47),
];
const TOPOLOGY: MatterNetworkTopology = {
collected_at: Date.now() / 1000,
nodes: NODES,
connections: CONNECTIONS,
};
const buildTopology = (): MatterNetworkTopology => ({
...TOPOLOGY,
collected_at: Date.now() / 1000,
});
const FABRICS: MatterFabricData[] = [
{
fabric_id: 1,
vendor_id: 4939,
fabric_index: 1,
fabric_label: "Home Assistant",
vendor_name: "Home Assistant",
},
];
const NODE_TYPE_BY_ROLE: Record<string, NodeType> = {
router: NodeType.ROUTING_END_DEVICE,
sleepy_end_device: NodeType.SLEEPY_END_DEVICE,
end_device: NodeType.END_DEVICE,
};
const NODES_BY_DEVICE_ID = new Map(
NODES.filter((node) => node.ha_device_id).map((node) => [
node.ha_device_id!,
node,
])
);
const nodeIpAddress = (node: MatterNetworkTopologyNode): string =>
node.network_type === "thread"
? `fd11:2233:4455:6677::${(node.node_id ?? 0).toString(16)}`
: `192.168.1.${100 + (node.node_id ?? 0)}`;
// Diagnostics are derived from the topology so a device's transport,
// availability and node type match the map. The device page reads them per
// device, and gates its actions on `available` and `network_type`.
const buildNodeDiagnostics = (
node: MatterNetworkTopologyNode
): MatterNodeDiagnostics => ({
node_id: node.node_id!,
network_type:
node.network_type === "thread" ? NetworkType.THREAD : NetworkType.WIFI,
node_type: node.is_bridge
? NodeType.BRIDGE
: (NODE_TYPE_BY_ROLE[node.role ?? ""] ?? NodeType.END_DEVICE),
network_name: node.network_name ?? node.ssid ?? undefined,
ip_adresses: [nodeIpAddress(node)],
mac_address: node.ext_address?.match(/.{2}/g)?.join(":"),
available: node.available !== false,
active_fabrics: FABRICS,
active_fabric_index: 1,
});
// The backend resolves the device before acting, so both node commands answer
// the same way when it cannot.
const nodeNotFound = (deviceId: string) =>
Promise.reject({
code: "node_not_found",
message: `No Matter node for device ${deviceId}`,
});
// The manual and QR codes below are the Matter test payload for passcode
// 20202021 with discriminator 3840; the three have to agree.
const COMMISSIONING_PARAMETERS: MatterCommissioningParameters = {
setup_pin_code: 20202021,
setup_manual_code: "34970112332",
setup_qr_code: "MT:Y.K9042C00KA0648G00",
};
const LOCK_INFO: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: ["pin"],
max_users: 10,
max_pin_users: 10,
max_rfid_users: null,
max_credentials_per_user: 2,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: null,
max_rfid_length: null,
};
const initialLockUsers = (): MatterLockUser[] => [
{
user_index: 1,
user_name: "Anne",
user_unique_id: 1,
user_status: "occupied_enabled",
user_type: "unrestricted_user",
credential_rule: "single",
credentials: [{ type: "pin", index: 1 }],
next_user_index: 2,
},
{
user_index: 2,
user_name: "Cleaner",
user_unique_id: 2,
user_status: "occupied_disabled",
user_type: "week_day_schedule_user",
credential_rule: "single",
credentials: [{ type: "pin", index: 2 }],
next_user_index: null,
},
];
// The manage dialog reloads the list after every add, edit and delete, so the
// mocked services keep the lock's users rather than answering from a constant,
// which would make every change look like it was reverted.
const lockUsers = new Map<string, MatterLockUser[]>();
const usersFor = (entityId: string): MatterLockUser[] => {
let users = lockUsers.get(entityId);
if (!users) {
users = initialLockUsers();
lockUsers.set(entityId, users);
}
return users;
};
// Lowest free slot, the way a lock hands out user and credential indexes.
const nextFreeIndex = (taken: number[]): number => {
let index = 1;
while (taken.includes(index)) {
index += 1;
}
return index;
};
export const mockMatter = (hass: MockHomeAssistant) => {
hass.mockWS("matter/network_topology", () => buildTopology());
hass.mockWS("matter/subscribe_network_topology", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.(buildTopology()))
);
hass.mockWS("matter/node_diagnostics", (msg: { device_id: string }) => {
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
return node ? buildNodeDiagnostics(node) : nodeNotFound(msg.device_id);
});
hass.mockWS("matter/ping_node", (msg: { device_id: string }) => {
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
return node
? { [nodeIpAddress(node)]: node.available !== false }
: nodeNotFound(msg.device_id);
});
hass.mockWS("matter/interview_node", () => undefined);
// Actions the device page offers for an available node. Without these the
// dialogs behind them fail with `command_not_mocked`.
hass.mockWS(
"matter/open_commissioning_window",
() => COMMISSIONING_PARAMETERS
);
hass.mockWS("matter/remove_matter_fabric", () => undefined);
hass.mockWS("matter/set_wifi_credentials", () => undefined);
hass.mockWS("matter/set_thread", () => undefined);
// The lock device exposes "Manage lock", whose dialog reads back the response
// of these services.
hass.mockService("matter", "get_lock_info", (_data, target) => ({
[target!.entity_id]: LOCK_INFO,
}));
hass.mockService("matter", "get_lock_users", (_data, target) => ({
// Copied, the way a real response would be: the dialog assigns the list to
// reactive state, so handing back the same array leaves it unchanged and
// the list never rerenders.
[target!.entity_id]: {
max_users: LOCK_INFO.max_users!,
users: usersFor(target!.entity_id).map((user) => ({
...user,
credentials: user.credentials.map((credential) => ({ ...credential })),
})),
} satisfies MatterLockUsersResponse,
}));
// Renames the user the credential below created, or edits an existing one.
hass.mockService("matter", "set_lock_user", (data, target) => {
const user = usersFor(target!.entity_id).find(
(candidate) => candidate.user_index === data?.user_index
);
if (user) {
if (data?.user_name !== undefined) {
user.user_name = data.user_name;
}
if (data?.user_type !== undefined) {
user.user_type = data.user_type;
}
if (data?.credential_rule !== undefined) {
user.credential_rule = data.credential_rule;
}
}
return {};
});
hass.mockService("matter", "clear_lock_user", (data, target) => {
const users = usersFor(target!.entity_id);
const index = users.findIndex(
(candidate) => candidate.user_index === data?.user_index
);
if (index !== -1) {
users.splice(index, 1);
}
return {};
});
// Adding a user starts here: the credential creates it, and the dialog reads
// the assigned index straight back to name it.
hass.mockService("matter", "set_lock_credential", (data, target) => {
const users = usersFor(target!.entity_id);
const userIndex =
(data?.user_index as number | null | undefined) ??
nextFreeIndex(
users
.map((user) => user.user_index)
.filter((i): i is number => i !== null)
);
const credentialIndex =
(data?.credential_index as number | null | undefined) ??
nextFreeIndex(
users.flatMap((user) =>
user.credentials
.map((credential) => credential.index)
.filter((i): i is number => i !== null)
)
);
const credential = {
type: (data?.credential_type as string) ?? "pin",
index: credentialIndex,
};
const user = users.find((candidate) => candidate.user_index === userIndex);
if (user) {
user.credentials = [...user.credentials, credential];
} else {
users.push({
user_index: userIndex,
user_name: null,
user_unique_id: userIndex,
user_status: data?.user_status ?? "occupied_enabled",
user_type: data?.user_type ?? "unrestricted_user",
credential_rule: "single",
credentials: [credential],
next_user_index: null,
});
}
return {
[target!.entity_id]: {
credential_index: credentialIndex,
user_index: userIndex,
next_credential_index: null,
} satisfies SetMatterLockCredentialResult,
};
});
};
@@ -1,90 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-mqtt";
const DEVICES = [
device(
"mqtt-fridge-sensor",
"Fridge sensor",
"Xiaomi",
"LYWSD03MMC",
ENTRY_ID,
{ area_id: "kitchen" }
),
device(
"mqtt-garage-door",
"Garage door",
"Shelly",
"Shelly Plus 1",
ENTRY_ID
),
];
const REGISTRY_ENTRIES = [
registryEntry(
"sensor.fridge_temperature",
"mqtt-fridge-sensor",
ENTRY_ID,
"mqtt"
),
registryEntry(
"sensor.fridge_battery",
"mqtt-fridge-sensor",
ENTRY_ID,
"mqtt"
),
registryEntry("cover.garage_door", "mqtt-garage-door", ENTRY_ID, "mqtt"),
];
export const mqttFixtures: ConnectivityFixtures = {
components: ["mqtt"],
// `execute_script` too: the panel publishes through a script action.
commands: ["mqtt/", "execute_script"],
manifests: [manifest("mqtt", "MQTT", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "mqtt", "core-mosquitto", {
supports_options: true,
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"sensor.fridge_temperature": {
entity_id: "sensor.fridge_temperature",
state: "21.4",
attributes: {
friendly_name: "Fridge temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"sensor.fridge_battery": {
entity_id: "sensor.fridge_battery",
state: "92",
attributes: {
friendly_name: "Fridge battery",
device_class: "battery",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"cover.garage_door": {
entity_id: "cover.garage_door",
state: "closed",
attributes: { friendly_name: "Garage door", device_class: "garage" },
},
}),
};
-192
View File
@@ -1,192 +0,0 @@
import type {
MQTTDeviceDebugInfo,
MQTTMessage,
} from "../../../../../src/data/mqtt";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
const PAYLOADS: Record<string, () => string> = {
"homeassistant/status": () => "online",
"zigbee2mqtt/bridge/state": () => '{"state":"online"}',
default: () =>
JSON.stringify({
battery: 92,
linkquality: 120,
// Built from whole tenths, so the payload never carries the noise a
// float sum leaves behind, like 21.599999999999998.
temperature: (214 + Math.floor(Math.random() * 11)) / 10,
}),
};
// Subscriptions take a topic filter, but a message carries the topic it was
// actually published on, so a filter has to be resolved to one concrete topic
// before it can be echoed back.
const resolveFilter = (filter: string): string =>
filter
.split("/")
.flatMap((level) => {
if (level === "+") {
return ["kitchen"];
}
if (level === "#") {
return ["kitchen", "temperature"];
}
return [level];
})
.join("/") || "homeassistant/status";
const buildMessage = (topic: string, qos: number): MQTTMessage => ({
topic,
payload: (PAYLOADS[topic] ?? PAYLOADS.default)(),
qos,
retain: 0,
time: new Date().toISOString(),
});
// A filter matches a topic level by level: "+" stands for one level, "#" for
// the rest of them.
const filterMatches = (filter: string, topic: string): boolean => {
const filterLevels = filter.split("/");
const topicLevels = topic.split("/");
for (let index = 0; index < filterLevels.length; index += 1) {
if (filterLevels[index] === "#") {
return true;
}
if (index >= topicLevels.length) {
return false;
}
if (
filterLevels[index] !== "+" &&
filterLevels[index] !== topicLevels[index]
) {
return false;
}
}
return filterLevels.length === topicLevels.length;
};
// The panel's listen card and its publish button talk to each other through
// the broker, so the mock keeps the subscriptions and delivers to them.
const subscriptions = new Set<{
filter: string;
qos: number;
deliver: (message: MQTTMessage) => void;
}>();
const topicDebug = (topic: string) => ({
topic,
messages: [buildMessage(topic, 0)],
});
// Keyed by device, the way the backend builds this per requested device.
const DEBUG_INFO: Record<string, MQTTDeviceDebugInfo> = {
"mqtt-fridge-sensor": {
entities: [
{
entity_id: "sensor.fridge_temperature",
discovery_data: {
topic: "homeassistant/sensor/fridge/temperature/config",
payload: {
name: "Temperature",
state_topic: "zigbee2mqtt/fridge",
unit_of_measurement: "°C",
device_class: "temperature",
},
},
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
transmitted: [],
},
{
entity_id: "sensor.fridge_battery",
discovery_data: {
topic: "homeassistant/sensor/fridge/battery/config",
payload: {
name: "Battery",
state_topic: "zigbee2mqtt/fridge",
unit_of_measurement: "%",
device_class: "battery",
},
},
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
transmitted: [],
},
],
triggers: [],
},
"mqtt-garage-door": {
entities: [
{
entity_id: "cover.garage_door",
discovery_data: {
topic: "homeassistant/cover/garage/config",
payload: {
name: "Garage door",
state_topic: "shellyplus1/status/cover:0",
command_topic: "shellyplus1/command/cover:0",
device_class: "garage",
},
},
subscriptions: [topicDebug("shellyplus1/status/cover:0")],
transmitted: [topicDebug("shellyplus1/command/cover:0")],
},
],
triggers: [],
},
};
export const mockMqtt = (hass: MockHomeAssistant) => {
hass.mockWS(
"mqtt/subscribe",
(msg: { topic: string; qos?: number }, _hass, onChange) => {
// Echo a message on the subscribed topic every few seconds so the
// listen card in the MQTT panel shows traffic.
const qos = msg.qos ?? 0;
const topic = resolveFilter(msg.topic);
const deliver = (message: MQTTMessage) => onChange?.(message);
const subscription = { filter: msg.topic, qos, deliver };
subscriptions.add(subscription);
const send = () => deliver(buildMessage(topic, qos));
const stopInitial = emitInitial(send);
const interval = window.setInterval(send, 3000);
return () => {
stopInitial();
clearInterval(interval);
subscriptions.delete(subscription);
};
}
);
// The panel publishes through a script action rather than a `mqtt/` command,
// so without this the publish button only ever reports a failure. Delivering
// to the matching subscriptions is what makes the two halves of the panel
// work together.
hass.mockWS(
"execute_script",
(msg: { sequence: { action?: string; data?: Record<string, any> }[] }) => {
msg.sequence
?.filter((action) => action.action === "mqtt.publish")
.forEach((action) => {
const topic = String(action.data?.topic ?? "");
const message: MQTTMessage = {
topic,
payload: String(action.data?.payload ?? ""),
qos: Number(action.data?.qos ?? 0),
retain: action.data?.retain ? 1 : 0,
time: new Date().toISOString(),
};
subscriptions.forEach((subscription) => {
if (filterMatches(subscription.filter, topic)) {
subscription.deliver(message);
}
});
});
return { context: { id: "mock-context" }, response: {} };
}
);
hass.mockWS(
"mqtt/device/debug_info",
(msg: { device_id: string }): MQTTDeviceDebugInfo =>
DEBUG_INFO[msg.device_id] ?? { entities: [], triggers: [] }
);
};
@@ -1,69 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
minutesAgo,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-rf-bridge";
const DEVICES = [
device(
"rf-bridge-garage",
"Garage bridge",
"Sonoff",
"RF Bridge R2",
ENTRY_ID,
{ area_id: "garden" }
),
device("rf-bridge-shed", "Shed bridge", "Sonoff", "RF Bridge R2", ENTRY_ID),
];
const REGISTRY_ENTRIES = [
registryEntry(
"radio_frequency.garage_bridge",
"rf-bridge-garage",
ENTRY_ID,
"esphome",
"Transceiver"
),
registryEntry(
"radio_frequency.shed_bridge",
"rf-bridge-shed",
ENTRY_ID,
"esphome",
"Transceiver"
),
];
export const radioFrequencyFixtures: ConnectivityFixtures = {
components: ["radio_frequency"],
commands: ["radio_frequency/"],
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
configEntries: [
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"radio_frequency.garage_bridge": {
entity_id: "radio_frequency.garage_bridge",
state: minutesAgo(47),
attributes: { friendly_name: "Garage bridge Transceiver" },
},
"radio_frequency.shed_bridge": {
entity_id: "radio_frequency.shed_bridge",
state: "unknown",
attributes: { friendly_name: "Shed bridge Transceiver" },
},
}),
backendTranslations: {
entity_component: {
"component.radio_frequency.entity_component._.name": "Transceiver",
},
},
};
@@ -1,25 +0,0 @@
import type { RadioFrequencyTransmitter } from "../../../../../src/data/radio_frequency";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
const TRANSMITTERS: RadioFrequencyTransmitter[] = [
{
entity_id: "radio_frequency.garage_bridge",
device_id: "rf-bridge-garage",
config_entry_id: "mock-rf-bridge",
supported_frequency_ranges: [[433920000, 433920000]],
supported_modulations: ["OOK"],
},
{
entity_id: "radio_frequency.shed_bridge",
device_id: "rf-bridge-shed",
config_entry_id: "mock-rf-bridge",
supported_frequency_ranges: [[433920000, 433920000]],
supported_modulations: ["OOK"],
},
];
export const mockRadioFrequency = (hass: MockHomeAssistant) => {
hass.mockWS("radio_frequency/list", () => ({
transmitters: TRANSMITTERS,
}));
};
@@ -1,6 +0,0 @@
import type { ConnectivityFixtures } from "../types";
export const serialFixtures: ConnectivityFixtures = {
components: ["usb"],
commands: ["usb/"],
};
-107
View File
@@ -1,107 +0,0 @@
import type { SerialPortUsage } from "../../../../../src/data/usb";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
const PORTS: SerialPortUsage[] = [
{
device: "/dev/ttyUSB0",
resolved_device:
"/dev/serial/by-id/usb-Nabu_Casa_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
manufacturer: "Nabu Casa",
description: "Home Assistant Connect ZBT-1",
interface_description: "Connect ZBT-1",
interface_num: 0,
vid: "10C4",
pid: "EA60",
bcd_device: 256,
matching_integrations: ["zha"],
present: true,
consumers: [
{
kind: "config_entry",
title: "Home Assistant Connect ZBT-1",
active: true,
domain: "zha",
config_entry_id: "mock-zha",
slug: null,
},
],
discovery_flows: [],
},
{
device: "/dev/ttyACM0",
resolved_device:
"/dev/serial/by-id/usb-Zooz_800_Z-Wave_Stick_533D004242-if00",
serial_number: "533D004242",
manufacturer: "Zooz",
description: "800 Series Z-Wave Long Range",
interface_description: null,
interface_num: 0,
vid: "10C4",
pid: "EA60",
bcd_device: 256,
matching_integrations: ["zwave_js"],
present: true,
consumers: [
{
kind: "config_entry",
title: "Z-Wave",
active: true,
domain: "zwave_js",
config_entry_id: "mock-zwave-js",
slug: null,
},
],
discovery_flows: [],
},
{
device: "/dev/ttyUSB1",
resolved_device:
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
serial_number: "A50285BI",
manufacturer: "FTDI",
description: "FT232R USB UART",
interface_description: null,
interface_num: 0,
vid: "0403",
pid: "6001",
bcd_device: 1536,
matching_integrations: [],
present: true,
consumers: [],
discovery_flows: [],
},
{
// A port that is configured but not currently plugged in. Its add-on icon
// is requested straight from /api/hassio/addons/<slug>/icon, which the
// demo has no backend for, so the icon stays blank here.
device: "/dev/ttyUSB2",
resolved_device: null,
serial_number: "0001",
manufacturer: "Silicon Labs",
description: "CP2102 USB to UART Bridge Controller",
interface_description: null,
interface_num: 0,
vid: "10C4",
pid: "EA60",
bcd_device: null,
matching_integrations: [],
present: false,
consumers: [
{
kind: "app",
title: "ESPHome Device Builder",
active: false,
domain: null,
config_entry_id: null,
slug: "esphome",
},
],
discovery_flows: [],
},
];
export const mockSerial = (hass: MockHomeAssistant) => {
hass.mockWS("usb/list_serial_ports", () => PORTS);
hass.mockWS("usb/scan", () => undefined);
};
@@ -1,10 +0,0 @@
// Mocked WebSocket subscriptions are registered synchronously, so a callback
// invoked straight away can land before the subscriber is ready for it: pages
// that ignore messages received before their first render drop it, and
// `createCollection` overwrites it with the empty initial fetch. Emitting the
// first message from a timeout matches the real backend, which always answers
// asynchronously.
export const emitInitial = (send: () => void): (() => void) => {
const timeout = window.setTimeout(send, 0);
return () => clearTimeout(timeout);
};
@@ -1,6 +0,0 @@
import type { ConnectivityFixtures } from "../types";
export const tagsFixtures: ConnectivityFixtures = {
components: ["tag"],
commands: ["tag/"],
};
@@ -1,31 +0,0 @@
import { manifest } from "../../manifest";
import { configEntry } from "../helpers";
import type { ConnectivityFixtures } from "../types";
const THREAD_ENTRY_ID = "mock-thread";
const OTBR_ENTRY_ID = "mock-otbr";
export const threadFixtures: ConnectivityFixtures = {
components: ["thread", "otbr"],
commands: ["thread/", "otbr/"],
manifests: [
manifest("thread", "Thread", {
integration_type: "service",
iot_class: "local_polling",
}),
manifest("otbr", "Open Thread Border Router", {
integration_type: "service",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "service",
entry: configEntry(THREAD_ENTRY_ID, "thread", "Thread"),
},
{
type: "service",
entry: configEntry(OTBR_ENTRY_ID, "otbr", "Open Thread Border Router"),
},
],
};
-441
View File
@@ -1,441 +0,0 @@
import type { OTBRInfoDict } from "../../../../../src/data/otbr";
import type {
ThreadDataSet,
ThreadRouter,
} from "../../../../../src/data/thread";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
const HA_EXT_PAN_ID = "DEAD00BEEF00CAFE";
const AMAZON_EXT_PAN_ID = "0011223344556677";
const OTBR_EXT_ADDRESS = "f6a1c30d2b4e5f61";
const OTBR_BORDER_AGENT_ID = "230c6a1ac57f6f4be262acf32e5ef52c";
const ROUTERS: ThreadRouter[] = [
{
instance_name: "HomeAssistant OpenThreadBorderRouter",
addresses: ["192.168.1.10"],
border_agent_id: OTBR_BORDER_AGENT_ID,
brand: "homeassistant",
extended_address: OTBR_EXT_ADDRESS,
extended_pan_id: HA_EXT_PAN_ID,
model_name: "OpenThread Border Router",
network_name: "ha-thread",
server: "core-openthread-border-router.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Home Assistant",
},
{
instance_name: "HomePod mini",
addresses: ["192.168.1.24"],
border_agent_id: "6a1ac57f6f4be262acf32e5ef52c230c",
brand: "apple",
extended_address: "aabbccddeeff0011",
extended_pan_id: HA_EXT_PAN_ID,
model_name: "HomePod mini",
network_name: "ha-thread",
server: "homepod-mini.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Apple Inc.",
},
{
instance_name: "Nest Hub",
addresses: ["192.168.1.31"],
border_agent_id: "ac57f6f4be262acf32e5ef52c230c6a1",
brand: "google",
extended_address: "bbccddeeff001122",
extended_pan_id: HA_EXT_PAN_ID,
model_name: "Google Nest Hub",
network_name: "ha-thread",
server: "nest-hub.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Google Inc.",
},
{
instance_name: "Echo (4th Gen)",
addresses: ["192.168.1.42"],
border_agent_id: "57f6f4be262acf32e5ef52c230c6a1ac",
brand: "amazon",
extended_address: "ccddeeff00112233",
extended_pan_id: AMAZON_EXT_PAN_ID,
model_name: "Echo",
network_name: "AmazonThread",
server: "amazon-echo.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Amazon",
},
];
const decodeUtf8 = (value: string): string | undefined => {
const bytes = Uint8Array.from(
(value.match(/../g) ?? []).map((byte) => parseInt(byte, 16))
);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return undefined;
}
};
const tlv = (type: number, value: string) =>
type.toString(16).padStart(2, "0").toUpperCase() +
(value.length / 2).toString(16).padStart(2, "0").toUpperCase() +
value.toUpperCase();
const textToHex = (text: string) =>
Array.from(text)
.map((character) => character.charCodeAt(0).toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
const replaceTlvField = (value: string, type: number, replacement: string) => {
let index = 0;
let out = "";
let replaced = false;
while (index + 4 <= value.length) {
const fieldType = parseInt(value.slice(index, index + 2), 16);
const length = parseInt(value.slice(index + 2, index + 4), 16);
const end = index + 4 + length * 2;
out +=
fieldType === type ? tlv(type, replacement) : value.slice(index, end);
replaced = replaced || fieldType === type;
index = end;
}
return replaced ? out : out + tlv(type, replacement);
};
const buildDatasetTlv = (dataset: ThreadDataSet) =>
[
tlv(0x0e, "0000000000010000"),
tlv(0x00, `00${(dataset.channel ?? 15).toString(16).padStart(4, "0")}`),
tlv(0x35, "000040010200"),
tlv(0x02, dataset.extended_pan_id),
tlv(0x07, "FD11220000000000"),
tlv(0x05, "00112233445566778899AABBCCDDEEFF"),
dataset.network_name === null
? ""
: tlv(0x03, textToHex(dataset.network_name)),
tlv(0x01, (dataset.pan_id ?? "1234").padStart(4, "0")),
tlv(0x04, "1035060004001FFFE00C0402A0F7F800"),
tlv(0x0c, "02A0F7F8"),
].join("");
const parseDatasetTlv = (value: string) => {
if (value.length % 2 !== 0 || !/^[0-9A-Fa-f]*$/.test(value)) {
return undefined;
}
const fields = new Map<number, string>();
let index = 0;
while (index < value.length) {
if (index + 4 > value.length) {
return undefined;
}
const type = parseInt(value.slice(index, index + 2), 16);
const length = parseInt(value.slice(index + 2, index + 4), 16);
const start = index + 4;
const end = start + length * 2;
if (end > value.length) {
return undefined;
}
if (fields.has(type)) {
return undefined;
}
fields.set(type, value.slice(start, end).toUpperCase());
index = end;
}
const extendedPanId = fields.get(0x02);
const networkName = fields.get(0x03);
const activeTimestamp = fields.get(0x0e);
if (
!extendedPanId ||
extendedPanId.length !== 16 ||
!activeTimestamp ||
activeTimestamp.length !== 16
) {
return undefined;
}
const decodedName =
networkName === undefined ? null : decodeUtf8(networkName);
if (decodedName === undefined) {
return undefined;
}
const channel = fields.get(0x00);
const channelNumber = channel ? parseInt(channel.slice(2), 16) : null;
if (channelNumber === 0) {
return undefined;
}
return {
activeTimestamp,
extendedPanId,
networkName: decodedName,
panId: fields.get(0x01) ?? null,
channel: channelNumber,
};
};
const DATASETS: ThreadDataSet[] = [
{
channel: 15,
created: new Date(Date.now() - 86400000 * 30).toISOString(),
dataset_id: "ha-thread-dataset",
extended_pan_id: HA_EXT_PAN_ID,
network_name: "ha-thread",
pan_id: "1234",
preferred_border_agent_id: OTBR_BORDER_AGENT_ID,
preferred_extended_address: OTBR_EXT_ADDRESS,
preferred: true,
source: "otbr",
},
];
const DATASET_TLVS: Record<string, string> = Object.fromEntries(
DATASETS.map((dataset) => [dataset.dataset_id, buildDatasetTlv(dataset)])
);
const OTBR_INFO: OTBRInfoDict = {
[OTBR_EXT_ADDRESS]: {
active_dataset_tlvs: DATASET_TLVS["ha-thread-dataset"],
border_agent_id: OTBR_BORDER_AGENT_ID,
channel: 15,
extended_address: OTBR_EXT_ADDRESS,
extended_pan_id: HA_EXT_PAN_ID,
url: "http://core-openthread-border-router:8081",
},
};
let added = 0;
let created = 0;
const randomExtendedPanId = () =>
Array.from({ length: 8 }, () =>
Math.floor(Math.random() * 256)
.toString(16)
.padStart(2, "0")
)
.join("")
.toUpperCase();
const moveRouter = (
extendedAddress: string,
extendedPanId: string,
networkName: string | null,
datasetId: string
) => {
const info = OTBR_INFO[extendedAddress];
const moved = DATASETS.find((item) => item.dataset_id === datasetId);
if (info) {
info.extended_pan_id = extendedPanId;
info.active_dataset_tlvs =
DATASET_TLVS[datasetId] ?? info.active_dataset_tlvs;
info.channel = moved?.channel ?? info.channel;
}
const router = ROUTERS.find(
(candidate) => candidate.extended_address === extendedAddress
);
if (router) {
router.extended_pan_id = extendedPanId;
router.network_name = networkName;
announce(router);
}
};
type RouterListener = (event: {
key: string;
type: "router_discovered" | "router_removed";
data: ThreadRouter;
}) => void;
const listeners = new Set<RouterListener>();
const announce = (router: ThreadRouter) =>
listeners.forEach((listener) =>
listener({
key: router.extended_address,
type: "router_discovered",
data: router,
})
);
export const mockThread = (hass: MockHomeAssistant) => {
hass.mockWS("thread/discover_routers", (_msg, _hass, onChange) => {
const listener = onChange as RouterListener | undefined;
if (listener) {
listeners.add(listener);
}
const stopInitial = emitInitial(() => ROUTERS.forEach(announce));
return () => {
stopInitial();
if (listener) {
listeners.delete(listener);
}
};
});
hass.mockWS("thread/list_datasets", () => ({
datasets: DATASETS.map((dataset) => ({ ...dataset })),
}));
hass.mockWS("thread/get_dataset_tlv", (msg: { dataset_id: string }) => {
const value = DATASET_TLVS[msg.dataset_id];
if (!value) {
throw new Error(`Dataset ${msg.dataset_id} not found`);
}
return { tlv: value };
});
hass.mockWS("otbr/info", () => OTBR_INFO);
hass.mockWS(
"thread/add_dataset_tlv",
(msg: { source: string; tlv: string }) => {
const parsed = parseDatasetTlv(msg.tlv);
if (!parsed) {
throw new Error("Invalid dataset");
}
const existing = DATASETS.find(
(candidate) => candidate.extended_pan_id === parsed.extendedPanId
);
if (existing) {
const current = parseDatasetTlv(DATASET_TLVS[existing.dataset_id]);
if (current && parsed.activeTimestamp <= current.activeTimestamp) {
return undefined;
}
existing.channel = parsed.channel;
existing.network_name = parsed.networkName;
existing.pan_id = parsed.panId;
DATASET_TLVS[existing.dataset_id] = msg.tlv.toUpperCase();
return undefined;
}
added += 1;
const dataset: ThreadDataSet = {
channel: parsed.channel,
created: new Date().toISOString(),
dataset_id: `added-dataset-${added}`,
extended_pan_id: parsed.extendedPanId,
network_name: parsed.networkName,
pan_id: parsed.panId,
preferred_border_agent_id: null,
preferred_extended_address: null,
preferred: false,
source: msg.source,
};
DATASETS.push(dataset);
DATASET_TLVS[dataset.dataset_id] = msg.tlv.toUpperCase();
return undefined;
}
);
hass.mockWS("thread/delete_dataset", (msg: { dataset_id: string }) => {
const index = DATASETS.findIndex(
(dataset) => dataset.dataset_id === msg.dataset_id
);
if (index === -1) {
throw new Error(`Dataset ${msg.dataset_id} not found`);
}
if (DATASETS[index].preferred) {
throw new Error("Preferred dataset cannot be deleted");
}
DATASETS.splice(index, 1);
delete DATASET_TLVS[msg.dataset_id];
return undefined;
});
hass.mockWS("thread/set_preferred_dataset", (msg: { dataset_id: string }) => {
DATASETS.forEach((dataset) => {
dataset.preferred = dataset.dataset_id === msg.dataset_id;
});
return undefined;
});
hass.mockWS(
"thread/set_preferred_border_agent",
(msg: {
dataset_id: string;
border_agent_id: string | null;
extended_address: string;
}) => {
const dataset = DATASETS.find(
(candidate) => candidate.dataset_id === msg.dataset_id
);
if (dataset) {
dataset.preferred_border_agent_id = msg.border_agent_id;
dataset.preferred_extended_address = msg.extended_address;
}
return undefined;
}
);
hass.mockWS("otbr/create_network", (msg: { extended_address: string }) => {
created += 1;
const dataset: ThreadDataSet = {
channel: 15,
created: new Date().toISOString(),
dataset_id: `created-dataset-${created}`,
extended_pan_id: randomExtendedPanId(),
network_name: `ha-thread-${created}`,
pan_id: "1234",
preferred_border_agent_id: null,
preferred_extended_address: null,
preferred: false,
source: "otbr",
};
DATASETS.push(dataset);
DATASET_TLVS[dataset.dataset_id] = buildDatasetTlv(dataset);
moveRouter(
msg.extended_address,
dataset.extended_pan_id,
dataset.network_name,
dataset.dataset_id
);
return undefined;
});
hass.mockWS(
"otbr/set_network",
(msg: { extended_address: string; dataset_id: string }) => {
const dataset = DATASETS.find(
(candidate) => candidate.dataset_id === msg.dataset_id
);
if (dataset) {
const info = OTBR_INFO[msg.extended_address];
if (info) {
info.channel = dataset.channel ?? info.channel;
}
moveRouter(
msg.extended_address,
dataset.extended_pan_id,
dataset.network_name,
dataset.dataset_id
);
}
return undefined;
}
);
hass.mockWS(
"otbr/set_channel",
(msg: { extended_address: string; channel: number }) => {
const info = OTBR_INFO[msg.extended_address];
if (info) {
info.channel = msg.channel;
const dataset = DATASETS.find(
(candidate) => candidate.extended_pan_id === info.extended_pan_id
);
if (dataset) {
dataset.channel = msg.channel;
DATASET_TLVS[dataset.dataset_id] = replaceTlvField(
DATASET_TLVS[dataset.dataset_id],
0x00,
`00${msg.channel.toString(16).padStart(4, "0")}`
);
info.active_dataset_tlvs = DATASET_TLVS[dataset.dataset_id];
}
}
return { delay: 120 };
}
);
};
-38
View File
@@ -1,38 +0,0 @@
import type { ConfigEntry } from "../../../../src/data/config_entries";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
import type {
IntegrationManifest,
IntegrationType,
} from "../../../../src/data/integration";
import type { TranslationCategory } from "../../../../src/data/translation";
import type { EntityInput } from "../../../../src/fake_data/entities/types";
export interface DemoConfigEntry {
entry: ConfigEntry;
type: IntegrationType;
}
/**
* Everything one connectivity integration contributes to the demo besides its
* WebSocket mocks. This is loaded eagerly, together with the registries, so it
* must not pull in the mocks (which are code-split into the config panel
* chunk). Each integration owns one of these, so they stay independent.
*/
export interface ConnectivityFixtures {
/** Components to load, so the integration's panel is reachable. */
components: string[];
/** WS command prefixes served by the integration's mock, if it has one. */
commands?: string[];
configEntries?: DemoConfigEntry[];
/** Manifests for the domains above, so their integration pages open. */
manifests?: IntegrationManifest[];
devices?: DeviceRegistryEntry[];
entityRegistryEntries?: EntityRegistryEntry[];
/** States for the entities above; built lazily so timestamps stay fresh. */
entities?: () => EntityInput[];
/** Backend translations the panel looks up, by category. */
backendTranslations?: Partial<
Record<TranslationCategory, Record<string, string>>
>;
}
-232
View File
@@ -1,232 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-zha";
export const COORDINATOR_IEEE = "00:12:4b:00:24:c2:e1:00";
export const PORCH_IEEE = "84:2e:14:ff:fe:11:22:33";
export const MOTION_IEEE = "cc:cc:cc:ff:fe:44:55:66";
export const PLUG_IEEE = "00:15:8d:00:03:aa:bb:cc";
export const KITCHEN_IEEE = "84:2e:14:ff:fe:aa:bb:01";
export const LANDING_IEEE = "00:15:8d:00:03:aa:bb:02";
export const GARAGE_IEEE = "00:15:8d:00:03:aa:bb:03";
export const OFFICE_IEEE = "00:15:8d:00:03:aa:bb:04";
const DEVICES = [
device(
"zha-coordinator",
"Home Assistant Connect ZBT-1",
"Nabu Casa",
"Connect ZBT-1",
ENTRY_ID,
{
sw_version: "7.4.4.0",
connections: [["zigbee", COORDINATOR_IEEE]],
identifiers: [["zha", COORDINATOR_IEEE]],
}
),
device(
"zha-porch-light",
"Porch light",
"IKEA of Sweden",
"TRADFRI bulb E27 CWS 806lm",
ENTRY_ID,
{
connections: [["zigbee", PORCH_IEEE]],
identifiers: [["zha", PORCH_IEEE]],
}
),
device(
"zha-hall-motion",
"Hall motion",
"IKEA of Sweden",
"TRADFRI motion sensor",
ENTRY_ID,
{
connections: [["zigbee", MOTION_IEEE]],
identifiers: [["zha", MOTION_IEEE]],
}
),
device("zha-tv-plug", "TV plug", "Innr", "SP 220", ENTRY_ID, {
area_id: "living_room",
connections: [["zigbee", PLUG_IEEE]],
identifiers: [["zha", PLUG_IEEE]],
}),
device(
"zha-kitchen-switch",
"Kitchen switch",
"IKEA of Sweden",
"TRADFRI on/off switch",
ENTRY_ID,
{
area_id: "kitchen",
connections: [["zigbee", KITCHEN_IEEE]],
identifiers: [["zha", KITCHEN_IEEE]],
}
),
device(
"zha-landing-sensor",
"Landing sensor",
"Aqara",
"WSDCGQ11LM",
ENTRY_ID,
{
connections: [["zigbee", LANDING_IEEE]],
identifiers: [["zha", LANDING_IEEE]],
}
),
device(
"zha-garage-contact",
"Garage contact",
"Aqara",
"MCCGQ11LM",
ENTRY_ID,
{
connections: [["zigbee", GARAGE_IEEE]],
identifiers: [["zha", GARAGE_IEEE]],
}
),
device("zha-office-plug", "Office plug", "Innr", "SP 240", ENTRY_ID, {
area_id: "office",
connections: [["zigbee", OFFICE_IEEE]],
identifiers: [["zha", OFFICE_IEEE]],
}),
];
const REGISTRY_ENTRIES = [
registryEntry("light.porch", "zha-porch-light", ENTRY_ID, "zha"),
registryEntry(
"binary_sensor.hall_motion",
"zha-hall-motion",
ENTRY_ID,
"zha"
),
registryEntry("switch.tv_plug", "zha-tv-plug", ENTRY_ID, "zha"),
registryEntry(
"sensor.kitchen_switch_battery",
"zha-kitchen-switch",
ENTRY_ID,
"zha"
),
registryEntry(
"sensor.landing_temperature",
"zha-landing-sensor",
ENTRY_ID,
"zha"
),
registryEntry(
"binary_sensor.garage_contact",
"zha-garage-contact",
ENTRY_ID,
"zha"
),
registryEntry("switch.office_desk", "zha-office-plug", ENTRY_ID, "zha"),
];
export const AREA_BY_IEEE: Record<string, string> = Object.fromEntries(
DEVICES.flatMap((entry) => {
const areaId = entry.area_id;
return areaId
? entry.connections
.filter(([type]) => type === "zigbee")
.map(([, ieee]) => [ieee, areaId])
: [];
})
);
export const zhaFixtures: ConnectivityFixtures = {
components: ["zha"],
commands: ["zha/"],
manifests: [
manifest("zha", "Zigbee Home Automation", {
integration_type: "hub",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "zha", "Home Assistant Connect ZBT-1", {
supports_options: true,
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"light.porch": {
entity_id: "light.porch",
state: "off",
attributes: {
friendly_name: "Porch light",
supported_color_modes: ["hs"],
},
},
"binary_sensor.hall_motion": {
entity_id: "binary_sensor.hall_motion",
state: "off",
attributes: { friendly_name: "Hall motion", device_class: "motion" },
},
"switch.tv_plug": {
entity_id: "switch.tv_plug",
state: "unavailable",
attributes: { friendly_name: "TV plug" },
},
"sensor.kitchen_switch_battery": {
entity_id: "sensor.kitchen_switch_battery",
state: "78",
attributes: {
friendly_name: "Kitchen switch battery",
device_class: "battery",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"sensor.landing_temperature": {
entity_id: "sensor.landing_temperature",
state: "19.6",
attributes: {
friendly_name: "Landing temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"binary_sensor.garage_contact": {
entity_id: "binary_sensor.garage_contact",
state: "off",
attributes: { friendly_name: "Garage contact", device_class: "door" },
},
"switch.office_desk": {
entity_id: "switch.office_desk",
state: "on",
attributes: { friendly_name: "Office desk" },
},
}),
backendTranslations: {
config_panel: {
"component.zha.config_panel.zha_options.title": "Global options",
"component.zha.config_panel.zha_options.default_light_transition":
"Default light transition time (seconds)",
"component.zha.config_panel.zha_options.enhanced_light_transition":
"Enable enhanced light color/temperature transition from an off state",
"component.zha.config_panel.zha_options.always_prefer_xy_color_mode":
"Always prefer XY color mode",
"component.zha.config_panel.zha_alarm_options.title": "Alarm options",
"component.zha.config_panel.zha_alarm_options.alarm_master_code":
"Alarm master code",
"component.zha.config_panel.zha_alarm_options.alarm_failed_tries":
"Failed authentication attempts before restart",
"component.zha.config_panel.zha_alarm_options.alarm_arm_requires_code":
"Code required for arming",
},
},
};
-793
View File
@@ -1,793 +0,0 @@
import type {
Attribute,
AttributeConfigurationStatus,
Cluster,
ClusterConfigurationEvent,
Command,
Neighbor,
ReadAttributeServiceData,
ZHAConfiguration,
ZHADevice,
ZHADeviceEndpoint,
ZHAGroup,
ZHAEntityReference,
ZHAGroupMember,
ZHANetworkBackup,
ZHANetworkSettings,
} from "../../../../../src/data/zha";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
import {
AREA_BY_IEEE,
LANDING_IEEE,
COORDINATOR_IEEE,
GARAGE_IEEE,
KITCHEN_IEEE,
MOTION_IEEE,
OFFICE_IEEE,
PLUG_IEEE,
PORCH_IEEE,
} from "./fixtures";
const neighbor = (
ieee: string,
nwk: string,
lqi: string,
relationship: string,
depth = "1"
): Neighbor => ({ ieee, nwk, lqi, depth, relationship });
const DEVICES: ZHADevice[] = [
{
available: true,
name: "Nabu Casa Connect ZBT-1",
ieee: COORDINATOR_IEEE,
nwk: 0x0000,
lqi: 255,
rssi: "0",
last_seen: minutesAgo(0),
manufacturer: "Nabu Casa",
model: "Connect ZBT-1",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-coordinator",
user_given_name: "Home Assistant Connect ZBT-1",
power_source: "Mains",
device_type: "Coordinator",
active_coordinator: true,
signature: {},
neighbors: [
neighbor(PORCH_IEEE, "0x1a2b", "224", "Child"),
neighbor(PLUG_IEEE, "0x3c4d", "198", "Child"),
neighbor(OFFICE_IEEE, "0x7a8b", "211", "Child"),
],
routes: [],
},
{
available: true,
name: "TRADFRI bulb E27 CWS 806lm",
ieee: PORCH_IEEE,
nwk: 0x1a2b,
lqi: 224,
rssi: "-58",
last_seen: minutesAgo(2),
manufacturer: "IKEA of Sweden",
model: "TRADFRI bulb E27 CWS 806lm",
quirk_applied: true,
quirk_class: "zhaquirks.ikea.bulb.IkeaBulb",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-porch-light",
user_given_name: "Porch light",
power_source: "Mains",
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [
neighbor(COORDINATOR_IEEE, "0x0000", "224", "Parent", "0"),
neighbor(MOTION_IEEE, "0x5e6f", "142", "Child", "2"),
neighbor(KITCHEN_IEEE, "0x9c0d", "186", "Sibling", "1"),
],
routes: [],
},
{
available: true,
name: "TRADFRI motion sensor",
ieee: MOTION_IEEE,
nwk: 0x5e6f,
lqi: 142,
rssi: "-77",
last_seen: minutesAgo(9),
manufacturer: "IKEA of Sweden",
model: "TRADFRI motion sensor",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-hall-motion",
user_given_name: "Hall motion",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
routes: [],
},
{
available: false,
name: "Innr SP 220",
ieee: PLUG_IEEE,
nwk: 0x3c4d,
lqi: 198,
rssi: "-64",
last_seen: minutesAgo(240),
manufacturer: "Innr",
model: "SP 220",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4448,
device_reg_id: "zha-tv-plug",
user_given_name: "TV plug",
power_source: "Mains",
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
routes: [],
},
{
available: true,
name: "TRADFRI on/off switch",
ieee: KITCHEN_IEEE,
nwk: 0x9c0d,
lqi: 186,
rssi: "-69",
last_seen: minutesAgo(4),
manufacturer: "IKEA of Sweden",
model: "TRADFRI on/off switch",
quirk_applied: true,
quirk_class: "zhaquirks.ikea.onoffswitch.IkeaSwitch",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-kitchen-switch",
user_given_name: "Kitchen switch",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
routes: [],
},
{
available: true,
name: "Aqara temperature sensor",
ieee: LANDING_IEEE,
nwk: 0xab12,
lqi: 164,
rssi: "-74",
last_seen: minutesAgo(6),
manufacturer: "Aqara",
model: "WSDCGQ11LM",
quirk_applied: true,
quirk_class: "zhaquirks.xiaomi.aqara.weather.Weather",
entities: [],
manufacturer_code: 4447,
device_reg_id: "zha-landing-sensor",
user_given_name: "Landing sensor",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
routes: [],
},
{
available: true,
name: "Aqara door sensor",
ieee: GARAGE_IEEE,
nwk: 0xcd34,
lqi: 118,
rssi: "-83",
last_seen: minutesAgo(21),
manufacturer: "Aqara",
model: "MCCGQ11LM",
quirk_applied: true,
quirk_class: "zhaquirks.xiaomi.aqara.magnet.Magnet",
entities: [],
manufacturer_code: 4447,
device_reg_id: "zha-garage-contact",
user_given_name: "Garage contact",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
routes: [],
},
{
available: true,
name: "Innr SP 240",
ieee: OFFICE_IEEE,
nwk: 0x7a8b,
lqi: 211,
rssi: "-61",
last_seen: minutesAgo(1),
manufacturer: "Innr",
model: "SP 240",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4448,
device_reg_id: "zha-office-plug",
user_given_name: "Office plug",
power_source: "Mains",
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [
neighbor(COORDINATOR_IEEE, "0x0000", "211", "Parent", "0"),
neighbor(LANDING_IEEE, "0xab12", "164", "Child", "2"),
neighbor(GARAGE_IEEE, "0xcd34", "118", "Child", "2"),
],
routes: [],
},
];
DEVICES.forEach((zhaDevice) => {
zhaDevice.area_id = AREA_BY_IEEE[zhaDevice.ieee];
});
const deviceByIeee = (ieee: string): ZHADevice =>
DEVICES.find((d) => d.ieee === ieee)!;
const ENDPOINT_ENTITIES: Record<string, { entity_id: string; name: string }[]> =
{
[PORCH_IEEE]: [{ entity_id: "light.porch", name: "Porch light" }],
[PLUG_IEEE]: [{ entity_id: "switch.tv_plug", name: "TV plug" }],
[OFFICE_IEEE]: [{ entity_id: "switch.office_desk", name: "Office desk" }],
};
const member = (ieee: string, endpointId = 1): ZHADeviceEndpoint => ({
device: deviceByIeee(ieee),
endpoint_id: endpointId,
entities: (ENDPOINT_ENTITIES[ieee] ?? []).map(
(entity) =>
({
...entity,
original_name: entity.name,
}) as ZHAEntityReference
),
});
const GROUPS: ZHAGroup[] = [
{
name: "Downstairs lights",
group_id: 1,
members: [member(PORCH_IEEE), member(OFFICE_IEEE)],
},
{
name: "Outdoor lights",
group_id: 2,
members: [member(PORCH_IEEE)],
},
];
const CONFIGURATION: ZHAConfiguration = {
data: {
zha_options: {
default_light_transition: 0,
enhanced_light_transition: false,
light_transitioning_flag: true,
always_prefer_xy_color_mode: true,
group_members_assume_state: true,
consider_unavailable_mains: 7200,
consider_unavailable_battery: 21600,
},
zha_alarm_options: {
alarm_master_code: "1234",
alarm_failed_tries: 3,
alarm_arm_requires_code: false,
},
},
schemas: {
zha_options: [
{
name: "default_light_transition",
required: true,
selector: { number: { min: 0, max: 2 ** 16 / 10, step: 0.1 } },
},
{
name: "enhanced_light_transition",
required: true,
selector: { boolean: {} },
},
{
name: "always_prefer_xy_color_mode",
required: true,
selector: { boolean: {} },
},
],
zha_alarm_options: [
{ name: "alarm_master_code", required: true, selector: { text: {} } },
{
name: "alarm_failed_tries",
required: true,
selector: { number: { min: 0, max: 2 ** 8, mode: "box" } },
},
{
name: "alarm_arm_requires_code",
required: true,
selector: { boolean: {} },
},
],
},
};
const BACKUPS: ZHANetworkBackup[] = [];
const NETWORK_SETTINGS: ZHANetworkSettings = {
radio_type: "ezsp",
device: { path: "/dev/ttyUSB0", baudrate: 115200, flow_control: "hardware" },
settings: {
backup_time: new Date(Date.now() - 3600000).toISOString(),
node_info: {
nwk: "0x0000",
ieee: COORDINATOR_IEEE,
logical_type: "coordinator",
},
network_info: {
extended_pan_id: "b0:23:2f:cc:aa:11:22:33",
pan_id: "0x1234",
nwk_update_id: 0,
nwk_manager_id: "0x0000",
channel: 15,
channel_mask: [15, 20, 25],
security_level: 5,
network_key: {
key: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
tx_counter: 138234,
rx_counter: 0,
seq: 0,
partner_ieee: "ff:ff:ff:ff:ff:ff:ff:ff",
},
tc_link_key: {
key: "5a:69:67:42:65:65:41:6c:6c:69:61:6e:63:65:30:39",
tx_counter: 0,
rx_counter: 0,
seq: 0,
partner_ieee: COORDINATOR_IEEE,
},
key_table: [],
children: [PORCH_IEEE, PLUG_IEEE, OFFICE_IEEE],
nwk_addresses: {
[PORCH_IEEE]: "0x1a2b",
[MOTION_IEEE]: "0x5e6f",
[PLUG_IEEE]: "0x3c4d",
[KITCHEN_IEEE]: "0x9c0d",
[LANDING_IEEE]: "0xab12",
[GARAGE_IEEE]: "0xcd34",
[OFFICE_IEEE]: "0x7a8b",
},
stack_specific: {},
metadata: { ezsp: { stack_version: "7.4.4.0" } },
source: "[email protected]",
},
},
};
interface ClusterDefinition {
name: string;
attributes: Attribute[];
commands: Command[];
}
const numberField = (name: string, max: number) => ({
name,
required: true,
selector: { number: { min: 0, max, mode: "box" as const } },
});
const CLUSTERS: Record<number, ClusterDefinition> = {
0: {
name: "Basic",
attributes: [
{ name: "zcl_version", id: 0 },
{ name: "app_version", id: 1 },
{ name: "manufacturer", id: 4 },
{ name: "model", id: 5 },
],
commands: [],
},
1: {
name: "PowerConfiguration",
attributes: [
{ name: "battery_voltage", id: 32 },
{ name: "battery_percentage_remaining", id: 33 },
],
commands: [],
},
3: {
name: "Identify",
attributes: [{ name: "identify_time", id: 0 }],
commands: [
{
name: "identify",
id: 0,
type: "server",
schema: [numberField("identify_time", 65535)],
},
],
},
4: {
name: "Groups",
attributes: [{ name: "name_support", id: 0 }],
commands: [],
},
6: {
name: "OnOff",
attributes: [{ name: "on_off", id: 0 }],
commands: [
{ name: "off", id: 0, type: "server", schema: [] },
{ name: "on", id: 1, type: "server", schema: [] },
{ name: "toggle", id: 2, type: "server", schema: [] },
],
},
8: {
name: "LevelControl",
attributes: [{ name: "current_level", id: 0 }],
commands: [
{
name: "move_to_level",
id: 0,
type: "server",
schema: [
numberField("level", 254),
numberField("transition_time", 65535),
],
},
],
},
25: {
name: "Ota",
attributes: [{ name: "current_file_version", id: 2 }],
commands: [],
},
768: {
name: "ColorControl",
attributes: [
{ name: "current_hue", id: 0 },
{ name: "current_saturation", id: 1 },
{ name: "color_temperature", id: 7 },
],
commands: [
{
name: "move_to_color_temp",
id: 10,
type: "server",
schema: [
numberField("color_temp_mireds", 500),
numberField("transition_time", 65535),
],
},
],
},
1026: {
name: "TemperatureMeasurement",
attributes: [
{ name: "measured_value", id: 0 },
{ name: "min_measured_value", id: 1 },
{ name: "max_measured_value", id: 2 },
],
commands: [],
},
1280: {
name: "IasZone",
attributes: [
{ name: "zone_state", id: 0 },
{ name: "zone_type", id: 1 },
{ name: "zone_status", id: 2 },
],
commands: [],
},
2820: {
name: "ElectricalMeasurement",
attributes: [
{ name: "rms_voltage", id: 1285 },
{ name: "rms_current", id: 1288 },
{ name: "active_power", id: 1291 },
],
commands: [],
},
};
const clusterList = (inIds: number[], outIds: number[] = []): Cluster[] =>
[
...inIds.map((id) => ({ id, type: "in" })),
...outIds.map((id) => ({ id, type: "out" })),
].map(({ id, type }) => ({
name: CLUSTERS[id].name,
id,
endpoint_id: 1,
type,
}));
// A device binds from its client side, and group binding lists only those, so
// the remotes carry the `out` clusters they would have on real hardware and
// the mains-powered devices only their OTA one.
const DEVICE_CLUSTERS: Record<string, Cluster[]> = {
[COORDINATOR_IEEE]: clusterList([0]),
[PORCH_IEEE]: clusterList([0, 3, 4, 6, 8, 768], [25]),
[MOTION_IEEE]: clusterList([0, 1, 3, 1280], [3, 6, 8]),
[PLUG_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
[KITCHEN_IEEE]: clusterList([0, 1, 3], [3, 6, 8]),
[LANDING_IEEE]: clusterList([0, 1, 3, 1026]),
[GARAGE_IEEE]: clusterList([0, 1, 3, 1280]),
[OFFICE_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
};
const DEFAULT_ATTRIBUTE_VALUES: Record<string, string> = {
"1:32": "30",
"1:33": "184",
"3:0": "0",
"4:0": "0",
"6:0": "1",
"8:0": "254",
"768:0": "42",
"768:1": "180",
"768:7": "370",
"1026:0": "2140",
"1026:1": "-2000",
"1026:2": "6000",
"1280:0": "1",
"1280:1": "21",
"1280:2": "0",
"2820:1285": "2300",
"2820:1288": "410",
"2820:1291": "94",
};
const writtenAttributes = new Map<string, string>();
const attributeKey = (data: ReadAttributeServiceData) =>
`${data.ieee}:${data.endpoint_id}:${data.cluster_id}:${data.attribute}`;
const attributeValue = (data: ReadAttributeServiceData): string => {
const written = writtenAttributes.get(attributeKey(data));
if (written !== undefined) {
return written;
}
if (data.cluster_id === 0) {
const device = DEVICES.find((candidate) => candidate.ieee === data.ieee);
if (data.attribute === 4) {
return device?.manufacturer ?? "";
}
if (data.attribute === 5) {
return device?.model ?? "";
}
return "3";
}
return (
DEFAULT_ATTRIBUTE_VALUES[`${data.cluster_id}:${data.attribute}`] ?? "0"
);
};
export const mockZha = (hass: MockHomeAssistant) => {
hass.mockWS("zha/devices", () => DEVICES);
hass.mockWS("zha/device", (msg: { ieee: string }) =>
DEVICES.find((device) => device.ieee === msg.ieee)
);
hass.mockWS("zha/groups", () => GROUPS);
hass.mockWS("zha/group", (msg: { group_id: number }) =>
GROUPS.find((group) => group.group_id === msg.group_id)
);
// Copied: both options editors mutate the fetched data as the user changes a
// control, so handing out the backing object would persist edits that were
// never saved. Only the update below writes to it.
hass.mockWS("zha/configuration", () => structuredClone(CONFIGURATION));
hass.mockWS("zha/network/settings", () => NETWORK_SETTINGS);
hass.mockWS("zha/topology/update", () => undefined);
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
DEVICES.filter(
(candidate) =>
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
)
);
hass.mockWS("zha/devices/bind", () => undefined);
hass.mockWS("zha/devices/unbind", () => undefined);
hass.mockWS("zha/groups/bind", () => undefined);
hass.mockWS("zha/groups/unbind", () => undefined);
hass.mockWS(
"zha/devices/clusters",
(msg: { ieee: string }) => DEVICE_CLUSTERS[msg.ieee] ?? []
);
hass.mockWS(
"zha/devices/clusters/attributes",
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.attributes ?? []
);
hass.mockWS(
"zha/devices/clusters/commands",
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.commands ?? []
);
hass.mockWS(
"zha/devices/clusters/attributes/value",
(msg: ReadAttributeServiceData) => attributeValue(msg)
);
hass.mockService("zha", "set_zigbee_cluster_attribute", (data) => {
const write = data as ReadAttributeServiceData & { value: unknown };
writtenAttributes.set(attributeKey(write), String(write.value));
return undefined;
});
hass.mockWS("zha/devices/groupable", () => [
member(PORCH_IEEE),
member(OFFICE_IEEE),
member(PLUG_IEEE),
]);
hass.mockWS("zha/network/backups/list", () => BACKUPS);
hass.mockWS("zha/devices/permit", () => () => undefined);
hass.mockWS(
"zha/devices/reconfigure",
(msg: { ieee: string }, _hass, onChange) => {
const deviceClusters = DEVICE_CLUSTERS[msg.ieee] ?? [];
const timers: number[] = [];
let cancelled = false;
const emit = (event: ClusterConfigurationEvent, step: number) => {
timers.push(
window.setTimeout(() => {
if (!cancelled) {
onChange!(event);
}
}, step * 400)
);
};
deviceClusters.forEach((cluster, index) => {
emit(
{
type: "zha_channel_bind",
zha_channel_msg_data: {
cluster_name: cluster.name,
cluster_id: cluster.id,
success: true,
},
},
index + 1
);
const attributes: AttributeConfigurationStatus[] = CLUSTERS[
cluster.id
].attributes.map((attribute) => ({
...attribute,
status: "SUCCESS",
min: 30,
max: 900,
change: 1,
}));
if (attributes.length) {
emit(
{
type: "zha_channel_configure_reporting",
zha_channel_msg_data: {
cluster_name: cluster.name,
cluster_id: cluster.id,
attributes,
},
},
index + 1
);
}
});
emit({ type: "zha_channel_cfg_done" }, deviceClusters.length + 1);
return () => {
cancelled = true;
timers.forEach((timer) => clearTimeout(timer));
};
}
);
hass.mockWS(
"zha/configuration/update",
(msg: { data: ZHAConfiguration["data"] }) => {
Object.entries(msg.data ?? {}).forEach(([section, values]) => {
CONFIGURATION.data[section] = {
...CONFIGURATION.data[section],
...values,
};
});
return undefined;
}
);
hass.mockWS("zha/network/backups/create", () => {
const backup: ZHANetworkBackup = {
backup_time: new Date().toISOString(),
// Copied, or changing the channel afterwards would rewrite the backup
// too, which is the one thing a backup must not do.
network_info: structuredClone(NETWORK_SETTINGS.settings.network_info),
node_info: structuredClone(NETWORK_SETTINGS.settings.node_info),
};
BACKUPS.push(backup);
return { backup, is_complete: true };
});
hass.mockWS(
"zha/network/change_channel",
(msg: { new_channel: "auto" | number }) => {
NETWORK_SETTINGS.settings.network_info.channel =
msg.new_channel === "auto" ? 25 : msg.new_channel;
return undefined;
}
);
hass.mockWS(
"zha/group/add",
(msg: {
group_name: string;
group_id?: number;
members?: ZHAGroupMember[];
}) => {
const group: ZHAGroup = {
name: msg.group_name,
group_id:
msg.group_id ??
GROUPS.reduce(
(highest, item) => Math.max(highest, item.group_id),
0
) + 1,
members: (msg.members ?? []).map((item) => member(item.ieee)),
};
GROUPS.push(group);
return group;
}
);
hass.mockWS("zha/group/remove", (msg: { group_ids: number[] }) => {
msg.group_ids.forEach((groupId) => {
const index = GROUPS.findIndex((group) => group.group_id === groupId);
if (index !== -1) {
GROUPS.splice(index, 1);
}
});
return GROUPS;
});
const findGroup = (groupId: number) => {
const group = GROUPS.find((candidate) => candidate.group_id === groupId);
if (!group) {
throw new Error(`Group ${groupId} not found`);
}
return group;
};
hass.mockWS(
"zha/group/members/add",
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
const group = findGroup(msg.group_id);
const known = new Set(group.members.map((item) => item.device.ieee));
group.members = [
...group.members,
...msg.members
.filter((item) => !known.has(item.ieee))
.map((item) => member(item.ieee)),
];
return group;
}
);
hass.mockWS(
"zha/group/members/remove",
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
const group = findGroup(msg.group_id);
const dropped = new Set(msg.members.map((item) => item.ieee));
group.members = group.members.filter(
(item) => !dropped.has(item.device.ieee)
);
return group;
}
);
};
@@ -1,201 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-zwave-js";
export const HOME_ID = 3245146787;
// Node IDs are carried in the `zwave_js` device identifiers, which is where the
// panels read them back from.
export const CONTROLLER_NODE_ID = 1;
export const HALLWAY_NODE_ID = 7;
export const DIMMER_NODE_ID = 12;
export const MOTION_NODE_ID = 15;
export const LOCK_NODE_ID = 18;
export const THERMOSTAT_NODE_ID = 20;
export const SENSOR_NODE_ID = 23;
export const OUTLET_NODE_ID = 31;
export const DEVICE_IDS_BY_NODE_ID: Record<number, string> = {
[CONTROLLER_NODE_ID]: "zwave-controller",
[HALLWAY_NODE_ID]: "zwave-hallway-switch",
[DIMMER_NODE_ID]: "zwave-dining-dimmer",
[MOTION_NODE_ID]: "zwave-garage-motion",
[LOCK_NODE_ID]: "zwave-back-door-lock",
[THERMOSTAT_NODE_ID]: "zwave-bedroom-thermostat",
[SENSOR_NODE_ID]: "zwave-basement-sensor",
[OUTLET_NODE_ID]: "zwave-porch-outlet",
};
const identifiers = (nodeId: number): [string, string][] => [
["zwave_js", `${HOME_ID}-${nodeId}`],
];
const DEVICES = [
device(
"zwave-controller",
"Z-Wave stick",
"Zooz",
"800 Series Z-Wave Long Range",
ENTRY_ID,
{ sw_version: "1.10", identifiers: identifiers(CONTROLLER_NODE_ID) }
),
device(
"zwave-dining-dimmer",
"Dining room dimmer",
"Inovelli",
"LZW31-SN",
ENTRY_ID,
{ identifiers: identifiers(DIMMER_NODE_ID) }
),
device("zwave-back-door-lock", "Back door lock", "Yale", "YRD226", ENTRY_ID, {
identifiers: identifiers(LOCK_NODE_ID),
}),
device(
"zwave-basement-sensor",
"Basement sensor",
"Aeotec",
"ZWA005 TriSensor",
ENTRY_ID,
{ identifiers: identifiers(SENSOR_NODE_ID) }
),
device("zwave-hallway-switch", "Hallway switch", "Zooz", "ZEN76", ENTRY_ID, {
area_id: "entrance",
identifiers: identifiers(HALLWAY_NODE_ID),
}),
device(
"zwave-garage-motion",
"Garage motion",
"Aeotec",
"MultiSensor 7",
ENTRY_ID,
{ identifiers: identifiers(MOTION_NODE_ID) }
),
device(
"zwave-bedroom-thermostat",
"Bedroom thermostat",
"Honeywell",
"T6 Pro",
ENTRY_ID,
{ area_id: "bedroom", identifiers: identifiers(THERMOSTAT_NODE_ID) }
),
device("zwave-porch-outlet", "Porch outlet", "Zooz", "ZEN15", ENTRY_ID, {
identifiers: identifiers(OUTLET_NODE_ID),
}),
];
const REGISTRY_ENTRIES = [
registryEntry(
"light.dining_room",
"zwave-dining-dimmer",
ENTRY_ID,
"zwave_js"
),
registryEntry("lock.back_door", "zwave-back-door-lock", ENTRY_ID, "zwave_js"),
registryEntry(
"sensor.basement_humidity",
"zwave-basement-sensor",
ENTRY_ID,
"zwave_js"
),
registryEntry("light.hallway", "zwave-hallway-switch", ENTRY_ID, "zwave_js"),
registryEntry(
"binary_sensor.garage_motion",
"zwave-garage-motion",
ENTRY_ID,
"zwave_js"
),
registryEntry(
"climate.bedroom",
"zwave-bedroom-thermostat",
ENTRY_ID,
"zwave_js"
),
registryEntry(
"switch.porch_outlet",
"zwave-porch-outlet",
ENTRY_ID,
"zwave_js"
),
];
export const zwaveJsFixtures: ConnectivityFixtures = {
components: ["zwave_js"],
commands: ["zwave_js/"],
manifests: [manifest("zwave_js", "Z-Wave", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "zwave_js", "Z-Wave", {
supports_options: true,
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"light.dining_room": {
entity_id: "light.dining_room",
state: "on",
attributes: {
friendly_name: "Dining room dimmer",
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 128,
},
},
"lock.back_door": {
entity_id: "lock.back_door",
state: "unlocked",
attributes: { friendly_name: "Back door lock" },
},
"sensor.basement_humidity": {
entity_id: "sensor.basement_humidity",
state: "58",
attributes: {
friendly_name: "Basement humidity",
device_class: "humidity",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"light.hallway": {
entity_id: "light.hallway",
state: "off",
attributes: {
friendly_name: "Hallway switch",
supported_color_modes: ["onoff"],
},
},
"binary_sensor.garage_motion": {
entity_id: "binary_sensor.garage_motion",
state: "on",
attributes: { friendly_name: "Garage motion", device_class: "motion" },
},
"climate.bedroom": {
entity_id: "climate.bedroom",
state: "heat",
attributes: {
friendly_name: "Bedroom thermostat",
hvac_modes: ["off", "heat"],
current_temperature: 19.6,
temperature: 20.5,
min_temp: 7,
max_temp: 30,
supported_features: 1,
},
},
"switch.porch_outlet": {
entity_id: "switch.porch_outlet",
state: "off",
attributes: { friendly_name: "Porch outlet" },
},
}),
};
@@ -1,229 +0,0 @@
import type {
ZWaveJSController,
ZWaveJSNetwork,
ZWaveJSNodeStatisticsUpdatedMessage,
ZWaveJSNodeStatus,
ZwaveJSProvisioningEntry,
} from "../../../../../src/data/zwave_js";
import {
NodeStatus,
ProvisioningEntryStatus,
SecurityClass,
} from "../../../../../src/data/zwave_js";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
import {
CONTROLLER_NODE_ID,
DEVICE_IDS_BY_NODE_ID,
DIMMER_NODE_ID,
HALLWAY_NODE_ID,
HOME_ID,
LOCK_NODE_ID,
MOTION_NODE_ID,
OUTLET_NODE_ID,
SENSOR_NODE_ID,
THERMOSTAT_NODE_ID,
} from "./fixtures";
const node = (
nodeId: number,
status: NodeStatus,
overrides: Partial<ZWaveJSNodeStatus> = {}
): ZWaveJSNodeStatus => ({
node_id: nodeId,
ready: true,
status,
is_secure: true,
is_routing: true,
zwave_plus_version: 2,
highest_security_class: SecurityClass.S2_Authenticated,
is_controller_node: false,
has_firmware_update_cc: true,
...overrides,
});
const NODES: ZWaveJSNodeStatus[] = [
node(CONTROLLER_NODE_ID, NodeStatus.Alive, {
is_controller_node: true,
highest_security_class: SecurityClass.S2_AccessControl,
}),
node(HALLWAY_NODE_ID, NodeStatus.Alive),
node(DIMMER_NODE_ID, NodeStatus.Alive),
node(MOTION_NODE_ID, NodeStatus.Asleep, { is_routing: false }),
node(LOCK_NODE_ID, NodeStatus.Asleep, {
is_routing: false,
highest_security_class: SecurityClass.S2_AccessControl,
}),
node(THERMOSTAT_NODE_ID, NodeStatus.Alive),
node(SENSOR_NODE_ID, NodeStatus.Dead, { is_routing: false }),
node(OUTLET_NODE_ID, NodeStatus.Alive),
];
const CONTROLLER: ZWaveJSController = {
home_id: HOME_ID,
sdk_version: "7.19.3",
type: 1,
own_node_id: CONTROLLER_NODE_ID,
rf_region: null,
is_primary: true,
is_using_home_id_from_other_network: false,
is_sis_present: true,
was_real_primary: true,
is_suc: true,
// NodeType.Controller; the enum itself is not exported from data/zwave_js.
node_type: 0 as ZWaveJSController["node_type"],
firmware_version: "1.10",
manufacturer_id: 634,
product_id: 4,
product_type: 3,
supported_function_types: [],
suc_node_id: CONTROLLER_NODE_ID,
supports_timers: false,
is_rebuilding_routes: false,
// InclusionState.Idle
inclusion_state: 0,
nodes: NODES,
supports_long_range: true,
};
const NETWORK: ZWaveJSNetwork = {
client: {
state: "connected",
ws_server_url: "ws://localhost:3000",
server_version: "1.40.1",
driver_version: "13.2.0",
},
controller: CONTROLLER,
};
const PROVISIONING_ENTRIES: ZwaveJSProvisioningEntry[] = [
{
dsk: "51590-27189-49239-34778-15304-59293-52843-45852",
securityClasses: [SecurityClass.S2_Authenticated],
status: ProvisioningEntryStatus.Active,
additionalProperties: {},
manufacturer: "Zooz",
label: "ZEN32 Scene Controller",
},
];
// Node IDs each node can reach directly. Only requested when the map's
// neighbor overlay is toggled on.
const NEIGHBORS: Record<number, number[]> = {
[CONTROLLER_NODE_ID]: [HALLWAY_NODE_ID, DIMMER_NODE_ID, OUTLET_NODE_ID],
[HALLWAY_NODE_ID]: [
CONTROLLER_NODE_ID,
DIMMER_NODE_ID,
LOCK_NODE_ID,
THERMOSTAT_NODE_ID,
],
[DIMMER_NODE_ID]: [
CONTROLLER_NODE_ID,
HALLWAY_NODE_ID,
SENSOR_NODE_ID,
OUTLET_NODE_ID,
],
[MOTION_NODE_ID]: [THERMOSTAT_NODE_ID],
[LOCK_NODE_ID]: [HALLWAY_NODE_ID],
[THERMOSTAT_NODE_ID]: [HALLWAY_NODE_ID, MOTION_NODE_ID],
[SENSOR_NODE_ID]: [DIMMER_NODE_ID],
[OUTLET_NODE_ID]: [CONTROLLER_NODE_ID, DIMMER_NODE_ID],
};
// Route each node reports as its last working route back to the controller,
// so the map can draw the mesh instead of a star.
const ROUTES: Record<number, { repeaters: number[]; rssi: number }> = {
[HALLWAY_NODE_ID]: { repeaters: [], rssi: -44 },
[DIMMER_NODE_ID]: { repeaters: [], rssi: -48 },
[OUTLET_NODE_ID]: { repeaters: [], rssi: -57 },
[LOCK_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -72 },
[THERMOSTAT_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -66 },
[MOTION_NODE_ID]: {
repeaters: [HALLWAY_NODE_ID, THERMOSTAT_NODE_ID],
rssi: -79,
},
[SENSOR_NODE_ID]: { repeaters: [DIMMER_NODE_ID], rssi: -81 },
};
const NODE_IDS_BY_DEVICE_ID: Record<string, number> = Object.fromEntries(
Object.entries(DEVICE_IDS_BY_NODE_ID).map(([nodeId, deviceId]) => [
deviceId,
Number(nodeId),
])
);
const buildNodeStatistics = (
nodeId: number
): ZWaveJSNodeStatisticsUpdatedMessage => {
const route = ROUTES[nodeId];
return {
event: "statistics updated",
source: "node",
nodeId,
node_id: nodeId,
commands_tx: 1200 + nodeId * 7,
commands_rx: 980 + nodeId * 5,
commands_dropped_tx: 0,
commands_dropped_rx: nodeId === SENSOR_NODE_ID ? 4 : 0,
timeout_response: 0,
rtt: 24 + nodeId,
rssi: route?.rssi ?? null,
lwr: route
? {
protocol_data_rate: 3,
repeaters: route.repeaters.map(
(repeaterNodeId) => DEVICE_IDS_BY_NODE_ID[repeaterNodeId]
),
rssi: route.rssi,
repeater_rssi: route.repeaters.map(() => -55),
route_failed_between: null,
}
: null,
nlwr: null,
};
};
export const mockZwaveJs = (hass: MockHomeAssistant) => {
hass.mockWS("zwave_js/network_status", () => NETWORK);
hass.mockWS("zwave_js/network_neighbors", () => NEIGHBORS);
hass.mockWS("zwave_js/get_provisioning_entries", () => PROVISIONING_ENTRIES);
hass.mockWS("zwave_js/data_collection_status", () => ({
enabled: false,
opted_in: false,
}));
hass.mockWS("zwave_js/subscribe_s2_inclusion", () => () => undefined);
hass.mockWS("zwave_js/node_status", (msg: { device_id: string }) => {
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
return NODES.find((n) => n.node_id === nodeId) ?? NODES[0];
});
hass.mockWS(
"zwave_js/subscribe_node_statistics",
(msg: { device_id: string }, _hass, onChange) => {
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
if (nodeId === undefined || nodeId === CONTROLLER_NODE_ID) {
return () => undefined;
}
return emitInitial(() => onChange?.(buildNodeStatistics(nodeId)));
}
);
hass.mockWS(
"zwave_js/subscribe_controller_statistics",
(_msg, _hass, onChange) =>
emitInitial(() =>
onChange?.({
event: "statistics updated",
source: "controller",
messages_tx: 18234,
messages_rx: 17980,
messages_dropped_tx: 2,
messages_dropped_rx: 5,
nak: 0,
can: 3,
timeout_ack: 1,
timeout_response: 0,
timeout_callback: 0,
})
)
);
};
-2
View File
@@ -1,5 +1,4 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
const baseDevice = {
config_entries_subentries: {},
@@ -87,5 +86,4 @@ export const demoDevices: DeviceRegistryEntry[] = [
entry_type: null,
parent_device_id: "power-strip",
},
...connectivityDevices,
];
+16 -11
View File
@@ -1,7 +1,19 @@
import type { IntegrationManifest } from "../../../src/data/integration";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { connectivityManifests } from "./connectivity/fixtures";
import { manifest } from "./manifest";
const manifest = (
domain: string,
name: string,
overrides: Partial<IntegrationManifest> = {}
): IntegrationManifest => ({
is_built_in: true,
domain,
name,
config_flow: true,
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
iot_class: "local_push",
...overrides,
});
const manifests: IntegrationManifest[] = [
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
@@ -50,18 +62,11 @@ const manifests: IntegrationManifest[] = [
integration_type: "helper",
iot_class: "local_polling",
}),
...connectivityManifests,
];
export const mockIntegration = (hass: MockHomeAssistant) => {
hass.mockWS("manifest/list", () => manifests);
// Never answer with undefined: the integration page reads the manifest it
// gets back without guarding, so an unlisted domain would throw. The real
// backend always has a manifest for a domain that has config entries.
hass.mockWS(
"manifest/get",
(msg: { integration: string }) =>
manifests.find((m) => m.domain === msg.integration) ??
manifest(msg.integration, msg.integration)
hass.mockWS("manifest/get", (msg: { integration: string }) =>
manifests.find((m) => m.domain === msg.integration)
);
};
-20
View File
@@ -1,20 +0,0 @@
import type { IntegrationManifest } from "../../../src/data/integration";
/**
* Builds a demo integration manifest. Lives in its own module so both the
* manifest registry and the per-integration fixtures that feed it can use it
* without importing each other.
*/
export const manifest = (
domain: string,
name: string,
overrides: Partial<IntegrationManifest> = {}
): IntegrationManifest => ({
is_built_in: true,
domain,
name,
config_flow: true,
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
iot_class: "local_push",
...overrides,
});
+2 -54
View File
@@ -1,58 +1,6 @@
import type { Tag, UpdateTagParams } from "../../../src/data/tag";
import type { Tag } from "../../../src/data/tag";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockTags = (hass: MockHomeAssistant) => {
const tags: Tag[] = [{ id: "my-tag", name: "My Tag" }];
let created = 0;
const find = (tagId: string) => tags.find((tag) => tag.id === tagId);
hass.mockWS("tag/list", () => tags.map((tag) => ({ ...tag })));
hass.mockWS(
"tag/create",
(msg: UpdateTagParams & { tag_id?: string }): Tag => {
if (msg.tag_id && find(msg.tag_id)) {
throw new Error(`Tag ${msg.tag_id} already exists`);
}
let id = msg.tag_id;
while (!id) {
created += 1;
id = find(`tag-${created}`) ? undefined : `tag-${created}`;
}
const tag: Tag = {
id,
name: msg.name,
description: msg.description,
};
tags.push(tag);
return { ...tag };
}
);
hass.mockWS(
"tag/update",
(msg: UpdateTagParams & { tag_id: string }): Tag => {
const tag = find(msg.tag_id);
if (!tag) {
throw new Error(`Tag ${msg.tag_id} not found`);
}
if ("name" in msg) {
tag.name = msg.name;
}
if ("description" in msg) {
tag.description = msg.description;
}
return { ...tag };
}
);
hass.mockWS("tag/delete", (msg: { tag_id: string }) => {
const index = tags.findIndex((tag) => tag.id === msg.tag_id);
if (index === -1) {
throw new Error(`Tag ${msg.tag_id} not found`);
}
tags.splice(index, 1);
return undefined;
});
hass.mockWS("tag/list", () => [{ id: "my-tag", name: "My Tag" }] as Tag[]);
};
+4 -23
View File
@@ -1,26 +1,7 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { connectivityBackendTranslations } from "./connectivity/fixtures";
export const mockTranslations = (
hass: MockHomeAssistant,
localizePromise?: Promise<LocalizeFunc>
) => {
hass.mockWS(
"frontend/get_translations",
(msg: { language: string; category?: string }) => ({
resources:
(msg.category && connectivityBackendTranslations[msg.category]) || {},
})
);
// `hass.loadBackendTranslation` is a no-op in the mocked hass, so categories
// that are only requested through it never reach the WebSocket mock above.
// Seed every category into the resources, after the fragment translations so
// this merges on top of them.
(localizePromise ?? Promise.resolve()).then(() =>
hass.addTranslations(
Object.assign({}, ...Object.values(connectivityBackendTranslations))
)
);
export const mockTranslations = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_translations", (
/* msg: {language: string, category: string} */
) => ({ resources: {} }));
};
+3 -1
View File
@@ -693,7 +693,9 @@ class HaGallery extends LitElement {
haStyle,
css`
:host {
user-select: initial;
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 300px;
--ha-sidebar-expanded-width: 300px;
--ha-sidebar-expanded-item-width: 292px;
-19
View File
@@ -16,25 +16,6 @@ const ENTITIES = [
duration: "0:05:00",
},
},
{
entity_id: "timer.active_timer",
state: "active",
attributes: {
friendly_name: "Active timer",
duration: "0:10:00",
remaining: "0:10:00",
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
},
},
{
entity_id: "timer.paused_timer",
state: "paused",
attributes: {
friendly_name: "Paused timer",
duration: "0:10:00",
remaining: "0:03:21",
},
},
];
@customElement("demo-more-info-timer")
+21 -19
View File
@@ -47,12 +47,12 @@
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.3",
"@codemirror/view": "6.43.11",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.9",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.1",
"@formatjs/intl-datetimeformat": "7.6.0",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
@@ -85,11 +85,12 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@tsparticles/engine": "4.3.2",
"@tsparticles/preset-links": "4.3.2",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
@@ -100,15 +101,16 @@
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.2",
"hls.js": "1.7.1",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.1",
"js-yaml": "5.3.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -116,7 +118,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.11",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -148,13 +150,13 @@
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.65.0",
"@lokalise/node-api": "16.4.1",
"@octokit/auth-oauth-device": "8.0.5",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.4",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.2.2",
"@rspack/core": "2.1.10",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -178,7 +180,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.9.1",
"eslint": "10.9.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -188,9 +190,9 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.4.0",
"generate-license-file": "4.2.5",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.12.0",
"globals": "17.11.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -200,12 +202,12 @@
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.4.1",
"lint-staged": "17.3.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"minify-literals": "2.1.0",
"pinst": "3.0.0",
"prettier": "3.9.6",
"rspack-manifest-plugin": "5.2.2",
@@ -215,7 +217,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.69.0",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
@@ -233,6 +235,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.20.0"
"node": "24.19.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.0"
version = "20260826.6"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+3 -7
View File
@@ -62,16 +62,12 @@ export function computeCssColor(color: string): string {
/**
* Get a color from document's styles
* @param color - Named theme color (examples: `red`, `primary-text`)
* @param style - Styles to resolve against, defaults to the document body
* @returns Resolved color; initial color if not found in the styles
* @returns Resolved color; initial color if not found in document's styles
*/
export function resolveThemeColor(
color: string,
style?: CSSStyleDeclaration
): string {
export function resolveThemeColor(color: string): string {
const cssColor = computeCssVariableName(color);
if (cssColor.startsWith("--")) {
const resolved = (style ?? getComputedStyle(document.body))
const resolved = getComputedStyle(document.body)
.getPropertyValue(cssColor)
.trim();
return resolved || color;
+5 -22
View File
@@ -1,4 +1,4 @@
import { parse, wcagLuminance, wcagContrast } from "culori";
import { wcagLuminance, wcagContrast } from "culori";
import { theme2hex } from "./convert-color";
/**
@@ -51,28 +51,11 @@ export const getRGBContrastRatio = (
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
/**
* Tells whether a color can be measured, which a CSS function that is passed
* through unevaluated cannot, and whether it covers what is behind it
* @param color - Color (HEX, rgb/rgba, named color) to check
* @returns Whether a contrast against this color says anything
*/
export const isOpaqueColor = (color: string): boolean => {
const parsed = parse(color.trim());
return parsed !== undefined && (parsed.alpha ?? 1) === 1;
};
/**
* Returns a contrasted color (black or white) for another color
* Returns a contrasted color (black or white) based on the luminance of another color
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
* @returns HEX color, whichever of black and white has the higher contrast ratio
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
*/
export const getContrastedColorHex = (color: string): string => {
const hex = theme2hex(color.trim());
// culori throws on a color it cannot read
if (!parse(hex)) {
return "#ffffff";
}
return wcagContrast(hex, "#000000") >= wcagContrast(hex, "#ffffff")
? "#000000"
: "#ffffff";
const lum = wcagLuminance(theme2hex(color));
return lum > 0.5 ? "#000000" : "#ffffff";
};
@@ -33,6 +33,7 @@ export const filterPanelStyles = css`
--ha-card-border-radius: var(--ha-border-radius-square);
}
ha-expansion-panel::part(summary) {
-webkit-user-select: none;
user-select: none;
}
.content {
@@ -1,152 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HaSlider } from "../../components/ha-slider";
import type { MediaPlayerEntity } from "../../data/media-player";
import { formatMediaTime, getCurrentProgress } from "../../data/media-player";
const PENDING_SEEK_TIMEOUT_MS = 5000;
const PENDING_SEEK_TOLERANCE_S = 2;
const PROGRESS_INTERVAL_MS = 1000;
export interface MediaProgressControllerOptions {
getStateObj: () => MediaPlayerEntity | undefined;
getSlider: () => HaSlider | undefined;
}
/**
* Drives a media progress slider: ticks it while the media plays, holds it
* on the target after a seek until the player state catches up, and leaves
* it alone while the user drags it. The controller owns the slider value;
* the host must not bind `.value` and reads `progress` for position text.
*/
export class MediaProgressController implements ReactiveController {
/** Current position in media seconds, pending seek included. */
public progress?: number;
private _host: ReactiveControllerHost;
private _options: MediaProgressControllerOptions;
private _interval?: number;
private _pendingPosition?: number;
private _pendingSince = 0;
constructor(
host: ReactiveControllerHost,
options: MediaProgressControllerOptions
) {
this._host = host;
this._options = options;
host.addController(this);
}
public hostUpdate(): void {
this._computeProgress();
}
public hostUpdated(): void {
this._writeSlider();
this._syncInterval();
}
public hostDisconnected(): void {
this._stopInterval();
}
/**
* Report a seek so the displayed position moves to the target immediately
* instead of jumping back until the player state reflects the seek.
*/
public seek(position: number): void {
this._pendingPosition = position;
this._pendingSince = Date.now();
this._tick();
}
private _tick(): void {
this._computeProgress();
this._writeSlider();
this._host.requestUpdate();
}
private _syncInterval(): void {
const stateObj = this._options.getStateObj();
if (
stateObj?.state === "playing" &&
stateObj.attributes.media_duration &&
stateObj.attributes.media_position !== undefined
) {
if (!this._interval) {
this._interval = window.setInterval(
() => this._tick(),
PROGRESS_INTERVAL_MS
);
}
} else {
this._stopInterval();
}
}
private _stopInterval(): void {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
private _computeProgress(): void {
const stateObj = this._options.getStateObj();
if (
!stateObj ||
!stateObj.attributes.media_duration ||
stateObj.attributes.media_position === undefined
) {
this.progress = undefined;
return;
}
this.progress = this._applyPendingSeek(
getCurrentProgress(stateObj),
stateObj.state === "playing",
stateObj.attributes.media_duration
);
}
private _applyPendingSeek(
current: number,
playing: boolean,
duration: number
): number {
if (this._pendingPosition === undefined) {
return current;
}
const elapsedMs = Date.now() - this._pendingSince;
const target = Math.min(
this._pendingPosition + (playing ? elapsedMs / 1000 : 0),
duration
);
if (
elapsedMs > PENDING_SEEK_TIMEOUT_MS ||
Math.abs(current - target) <= PENDING_SEEK_TOLERANCE_S
) {
this._pendingPosition = undefined;
return current;
}
return target;
}
private _writeSlider(): void {
const slider = this._options.getSlider();
if (!slider) {
return;
}
slider.valueFormatter = formatMediaTime;
if (slider.matches(":state(dragging)")) {
return;
}
slider.value = this.progress ?? 0;
}
}
@@ -1,70 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HassEntity } from "home-assistant-js-websocket";
import { timerTimeRemaining } from "../../data/timer";
/**
* Tracks the live remaining time of a timer entity. While the timer is
* active, the host is re-rendered every second with an updated
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
*
* The host must call `setStateObj` whenever its timer entity changes.
*/
export class TimerRemainingTimeController implements ReactiveController {
public timeRemaining?: number;
private _host: ReactiveControllerHost;
private _stateObj?: HassEntity;
private _interval?: number;
constructor(host: ReactiveControllerHost) {
this._host = host;
host.addController(this);
}
public setStateObj(stateObj: HassEntity | undefined): void {
this._stateObj = stateObj;
this._startInterval();
}
public hostConnected(): void {
this._startInterval();
}
public hostDisconnected(): void {
this._clearInterval();
}
private _startInterval(): void {
this._clearInterval();
if (!this._stateObj) {
this.timeRemaining = undefined;
return;
}
this._calculateRemaining();
if (this._stateObj.state === "active") {
this._interval = window.setInterval(() => {
this._calculateRemaining();
this._host.requestUpdate();
}, 1000);
}
}
private _clearInterval(): void {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
private _calculateRemaining(): void {
this.timeRemaining = this._stateObj
? timerTimeRemaining(this._stateObj)
: undefined;
}
}
+10 -5
View File
@@ -145,11 +145,16 @@ export const applyThemesOnElement = (
element.__themes = { cacheKey, keys: newTheme?.keys };
// Set and/or reset styles
for (const s in styles) {
if (s === null) {
element.style.removeProperty(s);
} else {
element.style.setProperty(s, styles[s]);
if (window.ShadyCSS) {
// Use ShadyCSS if available
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
} else {
for (const s in styles) {
if (s === null) {
element.style.removeProperty(s);
} else {
element.style.setProperty(s, styles[s]);
}
}
}
};
+25
View File
@@ -0,0 +1,25 @@
// Toggle Attribute Polyfill because it's too new for some browsers
export const toggleAttribute = (
el: HTMLElement,
name: string,
force?: boolean
) => {
if (force !== undefined) {
force = !!force;
}
if (el.hasAttribute(name)) {
if (force) {
return true;
}
el.removeAttribute(name);
return false;
}
if (force === false) {
return false;
}
el.setAttribute(name, "");
return true;
};
@@ -0,0 +1 @@
export const webComponentsSupported = "attachShadow" in Element.prototype;
+50
View File
@@ -0,0 +1,50 @@
/**
* ES5-compatible implementation of the keyed directive.
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
*
* This implementation avoids parameter destructuring in the update() method,
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
*
* Used only for ES5 builds (legacy browsers). Modern builds use the original
* lit-html keyed directive.
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
import { directive, Directive } from "lit-html/directive.js";
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {
private _key: unknown = nothing;
render(k: unknown, v: unknown) {
this._key = k;
return v;
}
update(part: unknown, args: [unknown, unknown]) {
const k = args[0];
const v = args[1];
if (k !== this._key) {
// Clear the part before returning a value. The one-arg form of
// setCommittedValue sets the value to a sentinel which forces a
// commit the next render.
setCommittedValue(part as Part);
this._key = k;
}
return v;
}
}
/**
* Associates a renderable value with a unique key. When the key changes, the
* previous DOM is removed and disposed before rendering the next value, even
* if the value - such as a template - is the same.
*
* This is useful for forcing re-renders of stateful components, or working
* with code that expects new data to generate new HTML elements, such as some
* animation techniques.
*/
export const keyed = directive(KeyedES5);
+9 -11
View File
@@ -12,14 +12,14 @@ import {
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
export const VECTOR_STYLES = {
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
@@ -33,8 +33,8 @@ const OSM_ATTRIBUTION =
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
export const CONTEXT_RESTORE_GRACE = 2000;
export const RECOVERY_THROTTLE = 30000;
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
@@ -55,7 +55,7 @@ export interface MapBaseLayer {
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
export const supportsWebGL2 = (): boolean => {
const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
@@ -91,7 +91,7 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
@@ -111,7 +111,7 @@ export const loadStyle = async (url: string): Promise<StyleSpecification> => {
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
@@ -303,11 +303,9 @@ export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined,
// Skip the vector layer, e.g. after a permanent WebGL context loss
rasterOnly = false
token: string | undefined
): Promise<MapBaseLayer> => {
if (!rasterOnly && supportsWebGL2()) {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
@@ -1,350 +0,0 @@
import type {
CircleMarker,
Control,
Map,
MarkerClusterGroup,
Polyline,
} from "leaflet";
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
import type { MapBaseLayer } from "../base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
import { setMarkerAccessibility } from "../marker-accessibility";
/** A leaflet marker that knows the engine handle it was created for */
interface HandledMarker extends DecoratedMarker {
engineHandle?: LeafletMarkerHandle;
}
interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/**
* The Leaflet implementation of MapEngine. Renders vector tiles through the
* maplibre-gl-leaflet adapter when WebGL2 is available and raster tiles
* otherwise (see createBaseLayer), so it is both the non-WebGL2 fallback and
* the engine ha-locations-editor requires for leaflet-draw.
*/
export class LeafletMapEngine implements MapEngine {
/**
* Escape hatch for ha-locations-editor, which manages its own Leaflet
* layers (leaflet-draw). Not for use anywhere else.
*/
public leafletMap?: Map;
public Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _clusterable: HandledMarker[] = [];
private _cluster?: MarkerClusterGroup;
private _clusterOptions: MapClusterOptions | null = null;
private _scaleControl?: Control.Scale;
public async init(
container: HTMLElement,
options: MapEngineOptions
): Promise<void> {
const root = container.parentNode;
if (!root) {
throw new Error("Cannot set up a Leaflet map on a detached element");
}
// eslint-disable-next-line
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
await import("leaflet.markercluster");
const map = Leaflet.map(container, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
for (const href of [
"/static/images/leaflet/leaflet.css",
"/static/images/leaflet/MarkerCluster.css",
]) {
const style = document.createElement("link");
style.setAttribute("href", href);
style.setAttribute("rel", "stylesheet");
root.appendChild(style);
}
map.setView(options.center, options.zoom);
// The base layer adds itself; a vector layer may still fall back to raster
this._baseLayer = await createBaseLayer(
Leaflet,
map,
options.darkMode,
options.token,
options.rasterOnly ?? false
);
this.leafletMap = map;
this.Leaflet = Leaflet;
map.zoomControl?.setPosition(options.zoomControlPosition);
const { events } = options;
if (events.click) {
map.on("click", (ev) => {
events.click!([ev.latlng.lat, ev.latlng.lng]);
});
}
if (events.zoomStart) {
map.on("zoomstart", () => events.zoomStart!());
}
if (events.moveStart) {
map.on("movestart", () => events.moveStart!());
}
}
public destroy(): void {
this.leafletMap?.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
this._cluster = undefined;
this._clusterable = [];
this._scaleControl = undefined;
}
public invalidateSize(): void {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
}
public hasUsableSize(): boolean {
if (!this.leafletMap) {
return false;
}
const size = this.leafletMap.getSize();
if (size.x > 0 && size.y > 0) {
return true;
}
const container = this.leafletMap.getContainer();
if (container.clientWidth > 0 && container.clientHeight > 0) {
// The container was laid out since Leaflet last measured it
this.leafletMap.invalidateSize(false);
return true;
}
return false;
}
public setDarkMode(darkMode: boolean): void {
this._baseLayer?.setDarkMode(darkMode);
}
public setZoomControlPosition(position: MapControlPosition): void {
this.leafletMap?.zoomControl?.setPosition(position);
}
public setScaleRuler(options: { metric: boolean } | null): void {
if (this._scaleControl) {
this.leafletMap?.removeControl(this._scaleControl);
this._scaleControl = undefined;
}
if (!options || !this.leafletMap || !this.Leaflet) {
return;
}
this._scaleControl = this.Leaflet.control.scale({
position: "bottomleft",
metric: options.metric,
imperial: !options.metric,
});
this._scaleControl.addTo(this.leafletMap!);
}
public setView(center: MapLatLng, zoom?: number): void {
this.leafletMap?.setView(center, zoom);
}
public setZoom(zoom: number): void {
this.leafletMap?.setZoom(zoom);
}
private _getZoom(): number {
return this.leafletMap?.getZoom() ?? 0;
}
private _project(location: MapLatLng): { x: number; y: number } {
const point = this.leafletMap!.project(location, this._getZoom());
return { x: point.x, y: point.y };
}
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
if (!this.leafletMap || !this.Leaflet || !points.length) {
return;
}
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
this.leafletMap.fitBounds(bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
});
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
const decoration = options.decoration
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
radius: options.decoration.radius,
})
: undefined;
// Leaflet's keyboard support focuses its own wrapper, where the element's
// activation handlers never hear a key; the element itself takes focus
const interactive = options.interactive ?? true;
if (interactive) {
element.tabIndex = 0;
}
setMarkerAccessibility(element, options.title, interactive);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive,
keyboard: false,
title: options.title,
});
const handle: LeafletMarkerHandle = {
marker,
location,
clusterData: options.clusterData,
remove: () => {
this._cluster?.removeLayer(marker);
marker.remove();
const index = this._clusterable.indexOf(marker);
if (index !== -1) {
this._clusterable.splice(index, 1);
}
},
};
marker.engineHandle = handle;
if (options.cluster) {
// Placed on the map by the next setClustering call
this._clusterable.push(marker);
} else {
marker.addTo(this.leafletMap!);
}
return handle;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
}
public addPath(path: MapPath): MapItemHandle {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
this.Leaflet!.polyline(segment.points, {
color: path.color,
opacity: segment.opacity,
interactive: false,
})
);
}
for (const pathMarker of path.markers) {
items.push(
this.Leaflet!.circleMarker(pathMarker.location, {
radius: isTouch ? 8 : 3,
color: path.color,
opacity: pathMarker.opacity,
fillOpacity: pathMarker.opacity,
interactive: true,
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
}
public setClustering(options: MapClusterOptions | null): void {
if (this._cluster) {
this._cluster.remove();
this._cluster = undefined;
}
this._clusterOptions = options;
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (!options) {
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
return;
}
// markercluster groups by proximity only; groupKey is not supported here
this._cluster = this.Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: options.radius,
iconCreateFunction: (cluster) => {
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
(marker) => marker.engineHandle!
);
const latLng = cluster.getLatLng();
const icon = this._clusterOptions!.iconBuilder(members, [
latLng.lat,
latLng.lng,
]);
// The element fills the divIcon wrapper, which gets the size
icon.element.style.width = `${icon.size[0]}px`;
icon.element.style.height = `${icon.size[1]}px`;
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
let anchor = icon.anchor;
if (icon.location) {
const clusterPoint = this._project([latLng.lat, latLng.lng]);
const targetPoint = this._project(icon.location);
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
anchor = [
base[0] - (targetPoint.x - clusterPoint.x),
base[1] - (targetPoint.y - clusterPoint.y),
];
}
return this.Leaflet!.divIcon({
html: icon.element,
iconSize: icon.size,
iconAnchor: anchor,
className: "",
});
},
});
this._cluster.addLayers(this._clusterable);
this.leafletMap!.addLayer(this._cluster!);
}
public refreshClusters(): void {
this._cluster?.refreshClusters();
}
}
File diff suppressed because it is too large Load Diff
-207
View File
@@ -1,207 +0,0 @@
/**
* Engine abstraction for ha-map.
*
* ha-map keeps all Home Assistant semantics (entities, zones, cluster bubble
* DOM, history path math, fit policy) and delegates the primitive map
* operations to a MapEngine, so the engine can be selected at runtime:
* MapLibre GL native where WebGL2 is available, Leaflet otherwise (and always
* for ha-locations-editor, which edits with leaflet-draw).
*
* The interface exposes no engine types: positions are [latitude, longitude]
* tuples and marker content is caller-owned HTML elements.
*
* Zoom levels use Leaflet semantics (zoom 0 = one world tile), the historical
* convention across Home Assistant map configs. The MapLibre engine converts
* internally (MapLibre zoom = Leaflet zoom - 1).
*/
export type MapLatLng = [latitude: number, longitude: number];
export interface MapPoint {
x: number;
y: number;
}
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
export interface MapEngineEvents {
/** Click on the map surface, not on a marker */
click(location: MapLatLng): void;
/** Zoom is starting, programmatic or not */
zoomStart(): void;
/** The map starts moving, programmatic or not */
moveStart(): void;
/** The engine can no longer render; the host switches to the fallback */
fatal(): void;
}
export interface MapEngineOptions {
center: MapLatLng;
zoom: number;
darkMode: boolean;
/** Token for core's tile proxy */
token?: string;
zoomControlPosition: MapControlPosition;
/** Render without WebGL after a permanent context loss; WebGL engines reject init */
rasterOnly?: boolean;
events: Partial<MapEngineEvents>;
}
export interface MapFitOptions {
/** Do not zoom in beyond this level even if the bounds would allow it */
maxZoom?: number;
/** Relative padding around the bounds, e.g. 0.5 grows them by 50% */
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
}
export interface MapMarkerOptions {
/** Rendered size of the element in pixels */
size: [width: number, height: number];
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
anchor?: [x: number, y: number];
/** Takes pointer input and keyboard focus; defaults to true */
interactive?: boolean;
/** Accessible name */
title?: string;
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
decoration?: MapCircleOptions;
/** Cluster this marker; it appears once setClustering is called */
cluster?: boolean;
/** Caller data handed back to the cluster icon builder */
clusterData?: unknown;
}
export interface MapCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
}
export interface MapPathSegment {
points: MapLatLng[];
opacity?: number;
}
export interface MapPathMarker {
location: MapLatLng;
opacity?: number;
/** Tooltip/popup HTML shown on hover; caller is responsible for escaping */
tooltipHtml: string;
}
export interface MapPath {
color: string;
segments: MapPathSegment[];
markers: MapPathMarker[];
}
/** Handle to anything placed on the map; remove() must be idempotent */
export interface MapItemHandle {
remove(): void;
}
export interface MapMarkerHandle extends MapItemHandle {
readonly location: MapLatLng;
readonly clusterData?: unknown;
}
export interface MapClusterIcon {
element: HTMLElement;
size: [width: number, height: number];
/** Like MapMarkerOptions.anchor; defaults to the element's center */
anchor?: [x: number, y: number];
/** Show the icon here instead of at the cluster, e.g. attached to a zone */
location?: MapLatLng;
}
export interface MapClusterOptions {
/** Cluster markers closer than this many screen pixels */
radius: number;
/**
* Markers sharing a key (e.g. their zone) form one group while they span
* at most groupRadius pixels; beyond that, and without a key, they cluster
* by proximity.
*/
groupKey?(marker: MapMarkerHandle): string | undefined;
groupRadius?: number;
/** Builds a cluster's element; called when its members change and on refreshClusters() */
iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon;
}
export interface MapEngine {
/** Create the map in the container; call once */
init(container: HTMLElement, options: MapEngineOptions): Promise<void>;
/** Tear down the map and release its resources (DOM, workers, WebGL) */
destroy(): void;
/** Re-measure the container after a size change */
invalidateSize(): void;
/** Whether the map has a non-zero size, re-measuring if needed */
hasUsableSize(): boolean;
setDarkMode(darkMode: boolean): void;
setZoomControlPosition(position: MapControlPosition): void;
/** Show a scale ruler (bottom start); null hides it */
setScaleRuler(options: { metric: boolean } | null): void;
setView(center: MapLatLng, zoom?: number): void;
setZoom(zoom: number): void;
/** Fit the given points into view; a single point centers on it */
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
// Content ------------------------------------------------------------
/**
* Place an HTML element on the map. The element is owned by the caller;
* the engine positions it and, for interactive markers, makes it
* focusable.
*/
addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle;
/** Draw a meter-radius circle (zone radius) */
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
/** Draw one history trail (points with tooltips, connecting segments) */
addPath(path: MapPath): MapItemHandle;
/**
* Enable or disable clustering of the markers added with cluster: true.
* Must be called after each batch of addMarker calls to place clusterable
* markers on the map; null places them unclustered.
*/
setClustering(options: MapClusterOptions | null): void;
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
refreshClusters(): void;
}
/**
* Bounding box corners of a circle, for fitting a radius into view without
* engine-specific circle bounds.
*/
export const circleBoundsPoints = (
center: MapLatLng,
radiusMeters: number
): MapLatLng[] => {
const latOffset = radiusMeters / 111320;
const lngOffset =
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
return [
[center[0] - latOffset, center[1] - lngOffset],
[center[0] + latOffset, center[1] + lngOffset],
];
};
-23
View File
@@ -1,23 +0,0 @@
/**
* Marker elements are focusable buttons on both engines, so the caller's
* element is the keyboard target and needs a name and a role. MapLibre would
* otherwise label it "Map marker".
*/
export const setMarkerAccessibility = (
element: HTMLElement,
title: string | undefined,
interactive: boolean
): void => {
if (title && !element.hasAttribute("aria-label")) {
element.setAttribute("aria-label", title);
}
if (!element.hasAttribute("role")) {
if (interactive) {
element.setAttribute("role", "button");
} else if (title) {
element.setAttribute("role", "img");
} else {
element.setAttribute("aria-hidden", "true");
}
}
};
+2 -191
View File
@@ -42,7 +42,6 @@ export const updateHistoryState = (patch: Record<string, unknown>) => {
*/
export const replaceCurrentUrl = (url: string) => {
mainWindow.history.replaceState(mainWindow.history.state, "", url);
rememberCurrentEntry();
};
/**
@@ -81,105 +80,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
return ensureDialogsClosed(timestamp);
};
/**
* Lets a page with unsaved changes (e.g. the automation editor) veto
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
* true when navigation may proceed.
*/
export interface UnsavedChangesGuard {
isDirty(): boolean;
prompt(): Promise<boolean>;
}
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.add(guard);
};
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.delete(guard);
};
const dirtyGuards = (): UnsavedChangesGuard[] =>
[...unsavedChangesGuards].filter((guard) => guard.isDirty());
let pendingUnsavedPrompt: Promise<boolean> | undefined;
/**
* Counts navigations that changed the history entry, so a navigation held up
* by an unsaved-changes prompt can tell whether a newer one has moved the app
* on in the meantime.
*/
let committedNavigations = 0;
interface HistoryEntry {
path: string;
/** Path of the entry behind this one, stamped by `performNavigation`. */
from?: string;
}
const readEntry = (): HistoryEntry => ({
path: currentPath(),
from: mainWindow.history.state?.from,
});
/**
* How far a pop moved from `entry`, signed, or undefined when it cannot be told:
* the entry behind us is the one our `from` names, the one ahead is the one
* whose `from` names us. A stack with the same path on both sides matches both,
* and back is then by far the likelier press.
*/
const popStep = (entry: HistoryEntry): number | undefined => {
if (currentPath() === entry.from) {
return -1;
}
return mainWindow.history.state?.from === entry.path ? 1 : undefined;
};
let currentEntry: HistoryEntry = readEntry();
const rememberCurrentEntry = () => {
currentEntry = readEntry();
};
/** Where the pops held while a prompt is open left us. */
let heldEntry: HistoryEntry | undefined;
let heldSteps = 0;
/** The entry `goBack()` asked to leave; the pop it triggers is not prompted. */
let popRequestedFromPath: string | undefined;
/**
* Asks each dirty guard whether navigation may proceed. Returns true when
* nothing is dirty or every prompt was confirmed. Concurrent navigations
* share one pending prompt instead of stacking dialogs; the dirty check runs
* before joining it, so a navigation triggered from inside a prompt (e.g. by
* its save action) cannot deadlock on its own promise.
*/
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
const guards = dirtyGuards();
if (!guards.length) {
return Promise.resolve(true);
}
if (!pendingUnsavedPrompt) {
pendingUnsavedPrompt = (async () => {
try {
for (const guard of guards) {
// eslint-disable-next-line no-await-in-loop
if (!(await guard.prompt())) {
return false;
}
}
return true;
} finally {
pendingUnsavedPrompt = undefined;
}
})();
}
return pendingUnsavedPrompt;
};
const buildHistoryState = (
data: Record<string, unknown> | undefined,
from?: string
@@ -191,7 +91,7 @@ const buildHistoryState = (
return { ...state, from };
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
@@ -224,32 +124,12 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
);
}
rememberCurrentEntry();
fireEvent(mainWindow, "location-changed", {
replace,
});
committedNavigations += 1;
return true;
};
export const navigate = async (path: string, options?: NavigateOptions) => {
// Only guard actual departures: navigating to the current path keeps the
// page, and any unsaved state on it, mounted.
if (path !== currentPath()) {
const navigationsBeforePrompt = committedNavigations;
if (!(await ensureUnsavedChangesConfirmed())) {
return false;
}
if (committedNavigations !== navigationsBeforePrompt) {
// Another navigation landed while the prompt was waiting for an answer,
// so this destination is stale. Dropping it keeps a late answer from
// pulling the user back off the page they are on now.
return false;
}
}
return performNavigation(path, options);
};
/**
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
@@ -262,9 +142,6 @@ export const canGoBack = (): boolean =>
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
* Deliberately not guarded against unsaved changes: pages with such a guard
* confirm in their own back handlers, and delete flows leave through here
* after the edited item is already gone.
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -275,75 +152,9 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
// Read after closing dialogs: their history entries are popped by then, so
// this is the state of the page entry.
if (canGoBack()) {
popRequestedFromPath = currentEntry.path;
mainWindow.history.back();
return;
}
await performNavigation(fallbackPath || "/", { replace: true });
};
/**
* Handles a history pop before the router acts on it. A pop cannot be cancelled,
* so one away from a page with unsaved changes holds the route and prompts;
* `resume` runs once the user agrees to leave. The prompt must add no history
* entry of its own, or the entries the pop left would be truncated.
*/
export const handleHistoryPop = (resume: () => void): void => {
if (heldEntry) {
const heldStep = popStep(heldEntry);
if (heldStep !== undefined) {
heldSteps += heldStep;
heldEntry = readEntry();
}
return;
}
if (currentPath() === currentEntry.path) {
// A dialog's history entry was popped, not a page.
rememberCurrentEntry();
resume();
return;
}
const step = popStep(currentEntry);
if (
popRequestedFromPath === currentEntry.path ||
// Not a pop we could undo, so do not hold it.
step === undefined ||
!dirtyGuards().length
) {
popRequestedFromPath = undefined;
rememberCurrentEntry();
committedNavigations += 1;
resume();
return;
}
heldSteps = step;
heldEntry = readEntry();
const navigationsAtPop = committedNavigations;
ensureUnsavedChangesConfirmed().then(
(confirmed) => {
const steps = heldSteps;
heldEntry = undefined;
if (committedNavigations !== navigationsAtPop) {
// A navigation landed while the prompt was open.
return;
}
if (!confirmed) {
// go(0) would reload the document, and at zero we are already back.
if (steps) {
mainWindow.history.go(-steps);
}
return;
}
rememberCurrentEntry();
committedNavigations += 1;
resume();
},
() => {
heldEntry = undefined;
}
);
await navigate(fallbackPath || "/", { replace: true });
};
+19
View File
@@ -0,0 +1,19 @@
export const startMediaProgressInterval = (
interval: number | undefined,
callback: () => void,
intervalMs = 1000
): number => {
if (interval) {
return interval;
}
return window.setInterval(callback, intervalMs);
};
export const stopMediaProgressInterval = (
interval: number | undefined
): number | undefined => {
if (interval) {
clearInterval(interval);
}
return undefined;
};
@@ -1,37 +0,0 @@
import deepFreeze from "deep-freeze";
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
export const shareInFlightRequest = <T>(
owner: object,
key: string,
fetcher: () => Promise<T>
): Promise<T> => {
let requests = inFlightRequests.get(owner);
if (!requests) {
requests = new Map();
inFlightRequests.set(owner, requests);
}
const ownerRequests = requests;
const existing = ownerRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const request = fetcher()
.then((result) => deepFreeze(result) as T)
.finally(() => {
if (ownerRequests.get(key) !== request) {
return;
}
ownerRequests.delete(key);
if (ownerRequests.size === 0) {
inFlightRequests.delete(owner);
}
});
ownerRequests.set(key, request);
return request;
};
@@ -328,7 +328,6 @@ export class StateHistoryChartLine extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
onFractionDigits: (digits) => {
if (digits !== this._yAxisFractionDigits) {
this._yAxisFractionDigits = digits;
-1
View File
@@ -446,7 +446,6 @@ export class StatisticsChart extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
// Bar charts stay anchored at 0, so precision must reflect the
// 0-based range that is actually rendered.
includeZero: !yAxisScale,
+26 -121
View File
@@ -1,32 +1,13 @@
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
import { getPrecision, nice, round } from "echarts/lib/util/number";
// A range smaller than this fraction of the axis magnitude is floating-point
// noise (e.g. from summed statistics), not real precision.
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
// precision derived here cannot drift from the ticks it renders.
const SPLIT_NUMBER = 5;
// How thin a gap between the data and the plot edge counts as no gap at all,
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
// maximum to a tick multiple, which usually leaves headroom, but quantized
// states often land exactly on a tick and get none — collapsing area-filled
// series, which are drawn from their value down to the axis minimum. Widening
// the extent by this much before that rounding bumps any axis with less
// headroom out to a full tick, and leaves the rest where they are.
const GAP_FRACTION_OF_SPAN = 0.02;
// A percentage has a real ceiling the way zero is a real floor, so the gap must
// not push the axis past it. Not every `%` sensor is bounded — power factor is
// signed and can read over 100 — so this only applies while the data stays under.
const PERCENT_MAX = 100;
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range, by asking ECharts for the same tick interval it will
// render. This matches the precision it actually draws, so labels are neither
// truncated to identical values nor padded with extra zeros.
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(
min: number,
max: number,
@@ -41,7 +22,13 @@ export function computeYAxisFractionDigits(
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
return getPrecision(nice(range / SPLIT_NUMBER, true));
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
// Rounding the mantissa to a nice value only ever carries to the next power
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
return Math.max(0, -niceExponent);
}
interface YAxisExtentValues {
@@ -57,116 +44,34 @@ const resolveYAxisBound = (
values: YAxisExtentValues
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
// A constant series has no span for the gap to be a fraction of, so ECharts
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
// expansion a flat series relies on for its window. Run that expansion here and
// return fixed bounds, which also suppresses the gap.
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
const precision = getPrecision(interval);
return {
min: round(Math.floor(lo / interval) * interval, precision),
max: round(Math.ceil(hi / interval) * interval, precision),
};
};
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
// callbacks with the extent of the visible (zoom-filtered) data on every
// dataZoom, and always before the label formatter runs, so the fraction digits
// recomputed here track the zoomed range. A callback returns `undefined`
// wherever auto-scaling should stand, and a number only where the axis has to be
// pinned: an explicit bound, the zero anchor, or a constant series.
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
// always before the label formatter runs, so recomputing the fraction digits
// here keeps zoomed-in labels distinct. The callbacks return the original
// bounds unchanged, so auto-scaling still applies when a bound is not set.
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
// Such an axis also gets no gap: pushing it below zero would defeat the zero
// anchoring and leave the bars floating above the axis.
includeZero?: boolean;
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
// a limit the data itself never crosses.
unit?: string;
onFractionDigits: (digits: number) => void;
}): {
min: (values: YAxisExtentValues) => number | undefined;
max: (values: YAxisExtentValues) => number | undefined;
boundaryGap: [number, number];
splitNumber: number;
} {
const { min, max, includeZero, unit, onFractionDigits } = options;
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
const resolveBounds = (values: YAxisExtentValues) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
if (
includeZero ||
!Number.isFinite(values.min) ||
!Number.isFinite(values.max)
) {
return { min: resolvedMin, max: resolvedMax, gap: 0 };
}
if (values.min === values.max) {
const flat = flatSeriesExtent(values.min, [
resolvedMin !== undefined,
resolvedMax !== undefined,
]);
// The expansion is a fraction of the magnitude, so a constant series near
// the ceiling would otherwise overshoot it too.
const flatMax =
naturalMax !== undefined && values.max <= naturalMax
? Math.min(flat.max, naturalMax)
: flat.max;
return {
min: resolvedMin ?? flat.min,
max: resolvedMax ?? flatMax,
gap: 0,
};
}
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
// Never let the gap carry a series past a boundary it does not itself cross.
const floor = values.min >= 0 ? 0 : undefined;
const ceiling =
naturalMax !== undefined && values.max <= naturalMax
? naturalMax
: values.max <= 0
? 0
: undefined;
return {
min:
resolvedMin ??
(floor !== undefined && values.min - floor < gap ? floor : undefined),
max:
resolvedMax ??
(ceiling !== undefined && ceiling - values.max < gap
? ceiling
: undefined),
gap,
};
};
const { min, max, includeZero, onFractionDigits } = options;
return {
// Always emit the key. `setOption` merges the Y axis rather than replacing
// it, so a conditionally spread gap would survive a chart switching to a
// zero-anchored type and leave its bars floating.
boundaryGap: includeZero
? [0, 0]
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
splitNumber: SPLIT_NUMBER,
min: (values) => {
const bounds = resolveBounds(values);
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
const extentMin = resolvedMin ?? values.min;
const extentMax = resolvedMax ?? values.max;
onFractionDigits(
computeYAxisFractionDigits(
bounds.min ?? values.min - bounds.gap,
bounds.max ?? values.max + bounds.gap,
includeZero
)
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
);
return bounds.min;
return resolvedMin;
},
max: (values) => resolveBounds(values).max,
max: (values) => resolveYAxisBound(max, values),
};
}
+36 -6
View File
@@ -204,17 +204,45 @@ export class HaDataTable extends LitElement {
this._checkedRowsChanged();
}
public selectAll(extraFilter?: (row: DataTableRowData) => boolean): void {
public selectAll(): void {
this._checkedRows = (this._filteredData || [])
.filter(
(data) =>
data.selectable !== false && (!extraFilter || extraFilter(data))
)
.filter((data) => data.selectable !== false)
.map((data) => data[this.id]);
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
public select(ids: string[], clear?: boolean): void {
if (clear) {
this._checkedRows = [];
}
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
const rowLookup = new Map(
(this._filteredData || []).map((data) => [data[this.id], data])
);
const checkedRows = new Set(this._checkedRows);
ids.forEach((id) => {
const row = rowLookup.get(id);
if (row?.selectable !== false && !checkedRows.has(id)) {
this._checkedRows.push(id);
checkedRows.add(id);
}
});
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
public unselect(ids: string[]): void {
ids.forEach((id) => {
const index = this._checkedRows.indexOf(id);
if (index > -1) {
this._checkedRows.splice(index, 1);
}
});
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
public connectedCallback() {
super.connectedCallback();
if (this._filteredData?.length) {
@@ -1207,8 +1235,10 @@ export class HaDataTable extends LitElement {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.mdc-data-table__header-row {
scrollbar-width: none;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.mdc-data-table__cell,
+1 -1
View File
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
getStatisticIds,
getStatisticLabel,
type StatisticsMetaData,
} from "../../data/recorder";
import { getStatisticIds } from "../../data/recorder_statistic_ids";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item";
+5
View File
@@ -293,7 +293,12 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
);
overflow: hidden;
color: var(--primary-text-color);
-webkit-backdrop-filter: var(
--ha-dialog-surface-backdrop-filter,
none
);
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
-webkit-user-select: text;
user-select: text;
}
+1 -1
View File
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
static styles = css`
.issue-type {
position: relative;
padding: var(--ha-alert-padding, 8px);
padding: 8px;
display: flex;
}
.icon {
+13
View File
@@ -1061,18 +1061,31 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2s infinite ease-in-out;
animation: sk-bounce 2s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1s;
animation-delay: -1s;
}
@-webkit-keyframes sk-bounce {
0%,
100% {
-webkit-transform: scale(0);
}
50% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
+1
View File
@@ -70,6 +70,7 @@ export class HaBadge extends LitElement {
--ha-card-background,
var(--card-background-color, white)
);
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
backdrop-filter: var(--ha-card-backdrop-filter, none);
border-width: var(--ha-card-border-width, 1px);
box-shadow: var(--ha-card-box-shadow, none);
+11
View File
@@ -432,6 +432,13 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
}
}
wa-drawer::part(dialog)::backdrop {
-webkit-backdrop-filter: var(
--ha-bottom-sheet-scrim-backdrop-filter,
var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
)
);
backdrop-filter: var(
--ha-bottom-sheet-scrim-backdrop-filter,
var(
@@ -463,6 +470,10 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
var(--card-background-color, var(--ha-color-surface-default))
)
);
-webkit-backdrop-filter: var(
--ha-bottom-sheet-surface-backdrop-filter,
var(--ha-dialog-surface-backdrop-filter, none)
);
backdrop-filter: var(
--ha-bottom-sheet-surface-backdrop-filter,
var(--ha-dialog-surface-backdrop-filter, none)
+1
View File
@@ -13,6 +13,7 @@ export class HaCard extends LitElement {
--ha-card-background,
var(--card-background-color, white)
);
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
backdrop-filter: var(--ha-card-backdrop-filter, none);
box-shadow: var(--ha-card-box-shadow, none);
box-sizing: border-box;
+5 -12
View File
@@ -56,7 +56,7 @@ export class HaControlSelect extends LitElement {
this.updateComplete.then(() => {
// eslint-disable-next-line lit/prefer-query-decorators
const option = this.shadowRoot?.querySelector(
`[data-index="${index}"]`
`#option-${this.options![index].value}`
) as HTMLElement;
option?.focus();
});
@@ -144,11 +144,7 @@ export class HaControlSelect extends LitElement {
this.options,
(option) => option.value,
(option, index) =>
this._renderOption(
option,
index,
index === this._tabbableIndex
)
this._renderOption(option, index === this._tabbableIndex)
)
: nothing
}
@@ -163,17 +159,12 @@ export class HaControlSelect extends LitElement {
return selectedIndex === -1 ? 0 : selectedIndex;
}
private _renderOption(
option: ControlSelectOption,
index: number,
tabbable: boolean
) {
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
const isSelected = this.value === option.value;
return html`
<div
id=${`option-${option.value}`}
data-index=${index}
class=${classMap({
option: true,
selected: isSelected,
@@ -332,6 +323,8 @@ export class HaControlSelect extends LitElement {
.option .content span {
display: block;
width: 100%;
-webkit-hyphens: auto;
-moz-hyphens: auto;
hyphens: auto;
}
:host([vertical]) {
+8
View File
@@ -393,6 +393,10 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
}
wa-dialog::part(dialog) {
-webkit-backdrop-filter: var(
--ha-dialog-surface-backdrop-filter,
none
);
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
box-shadow: var(--dialog-box-shadow, var(--wa-shadow-l));
color: var(--primary-text-color);
@@ -425,6 +429,10 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
}
wa-dialog::part(dialog)::backdrop {
-webkit-backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
-4
View File
@@ -95,10 +95,6 @@ export class HaDrawer extends LitElement {
}
private _handleAfterHide(ev: Event) {
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
if (ev.target !== ev.currentTarget) {
return;
}
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
+5
View File
@@ -63,6 +63,11 @@ class HaFaded extends LitElement {
}
.faded {
cursor: pointer;
-webkit-mask-image: linear-gradient(
to bottom,
black 25%,
transparent 100%
);
mask-image: linear-gradient(to bottom, black 25%, transparent 100%);
overflow-y: hidden;
}
@@ -1,13 +1,6 @@
import {
getSelectorInitialValue,
getSelectorInitialValueOrUndefined,
} from "./get-selector-initial-value";
import type { Selector } from "../../data/selector";
import type { HaFormData, HaFormSchema } from "./types";
interface ComputeInitialHaFormDataOptions {
skipUnsupportedSelectors?: boolean;
}
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
@@ -25,8 +18,7 @@ const setDefaultValue = (
};
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[],
options?: ComputeInitialHaFormDataOptions
schema: HaFormSchema[] | readonly HaFormSchema[]
): Record<string, any> => {
const data = {};
schema.forEach((field) => {
@@ -41,7 +33,7 @@ export const computeInitialHaFormData = (
} else if ("default" in field) {
data[field.name] = setDefaultValue(field, field.default);
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema, options);
const expandableData = computeInitialHaFormData(field.schema);
if (field.required || Object.keys(expandableData).length) {
// Only add expandable data if it's required or any of its children have initial values.
data[field.name] = expandableData;
@@ -70,11 +62,104 @@ export const computeInitialHaFormData = (
seconds: 0,
};
} else if ("selector" in field) {
const initialValue = options?.skipUnsupportedSelectors
? getSelectorInitialValueOrUndefined(field.selector)
: getSelectorInitialValue(field.selector);
if (initialValue !== undefined) {
data[field.name] = initialValue;
const selector: Selector = field.selector;
if ("device" in selector) {
data[field.name] = selector.device?.multiple ? [] : "";
} else if ("entity" in selector) {
data[field.name] = selector.entity?.multiple ? [] : "";
} else if ("area" in selector) {
data[field.name] = selector.area?.multiple ? [] : "";
} else if ("label" in selector) {
data[field.name] = selector.label?.multiple ? [] : "";
} else if ("boolean" in selector) {
data[field.name] = false;
} else if (
"addon" in selector ||
"attribute" in selector ||
"file" in selector ||
"icon" in selector ||
"serial_port" in selector ||
"template" in selector ||
"text" in selector ||
"theme" in selector ||
"object" in selector
) {
data[field.name] = "";
} else if ("number" in selector) {
data[field.name] = selector.number?.min ?? 0;
} else if ("select" in selector) {
if (selector.select?.options.length) {
const firstOption = selector.select.options[0];
const val =
typeof firstOption === "string" ? firstOption : firstOption.value;
data[field.name] = selector.select.multiple ? [val] : val;
}
} else if ("country" in selector) {
if (selector.country?.countries?.length) {
data[field.name] = selector.country.countries[0];
}
} else if ("language" in selector) {
if (selector.language?.languages?.length) {
data[field.name] = selector.language.languages[0];
}
} else if ("duration" in selector) {
data[field.name] = {
hours: 0,
minutes: 0,
seconds: 0,
};
} else if ("time" in selector) {
data[field.name] = "00:00:00";
} else if ("date" in selector || "datetime" in selector) {
const now = new Date().toISOString().slice(0, 10);
data[field.name] = `${now}T00:00:00`;
} else if ("color_rgb" in selector) {
data[field.name] = [0, 0, 0];
} else if ("color_temp" in selector) {
data[field.name] = selector.color_temp?.min_mireds ?? 153;
} else if (
"action" in selector ||
"trigger" in selector ||
"condition" in selector
) {
data[field.name] = [];
} else if ("media" in selector || "target" in selector) {
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
);
}
}
});
@@ -1,90 +1,25 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorFallbackValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_FALLBACK_VALUES = {
action: undefined,
addon: undefined,
automation_behavior: undefined,
app: undefined,
area: undefined,
areas_display: undefined,
attribute: undefined,
assist_pipeline: undefined,
boolean: () => false,
choose: undefined,
color_rgb: undefined,
condition: undefined,
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: undefined,
date: undefined,
datetime: undefined,
device: undefined,
device_class: undefined,
duration: undefined,
entity: undefined,
entity_name: undefined,
statistic: undefined,
file: undefined,
floor: undefined,
label: undefined,
language: undefined,
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
selector: undefined,
serial_port: undefined,
state: undefined,
state_class: undefined,
backup_location: undefined,
stt: undefined,
target: undefined,
template: undefined,
text: undefined,
time: undefined,
icon: undefined,
media: undefined,
theme: undefined,
timezone: undefined,
button_toggle: undefined,
trigger: undefined,
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
},
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorFallbackValues;
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
return fallbackValue?.(selector as never);
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};
@@ -1,141 +0,0 @@
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);
};
+6 -26
View File
@@ -1,5 +1,4 @@
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";
@@ -13,7 +12,6 @@ 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";
@@ -122,8 +120,6 @@ 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>;
@@ -134,8 +130,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@state() private _popoverWidth = 0;
@state() private _popoverMinHeight = 0;
@state() private _openedNarrow = false;
@state() private _unknownValue = false;
@@ -246,12 +240,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style=${styleMap({
"--body-width": `${this._popoverWidth}px`,
"--body-min-height": `${this._popoverMinHeight}px`,
})}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="0"
distance="-4"
.placement=${this.popoverPlacement}
.for=${this.popoverAnchor ? null : "picker"}
.anchor=${this.popoverAnchor ?? null}
@@ -274,14 +265,12 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
private _renderComboBox(dialogMode = false) {
// The list sizes the popover, so it is rendered as it opens, not once shown.
if (!this._pickerWrapperOpen && !this._opened) {
if (!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}
@@ -350,8 +339,6 @@ 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
@@ -385,7 +372,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._opened = false;
this._pickerWrapperOpen = false;
this._popoverMinHeight = 0;
this._selectedValue = undefined;
this._unsubscribeTinyKeys?.();
fireEvent(this, "picker-closed");
@@ -488,8 +474,6 @@ 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 {
@@ -502,18 +486,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
--ha-generic-picker-max-width,
var(--ha-generic-picker-width, max(var(--body-width), 250px))
);
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);
max-height: 500px;
height: 70vh;
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: min(70vh, 400px);
max-height: 400px;
}
}
-1
View File
@@ -31,7 +31,6 @@ 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);
}
+3 -1
View File
@@ -53,7 +53,9 @@ export class HaMarkdown extends LitElement {
display: block;
}
ha-markdown-element {
user-select: text;
-ms-user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
}
ha-markdown-element > *:first-child {
margin-top: 0;
-445
View File
@@ -1,445 +0,0 @@
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;
}
}
+68 -151
View File
@@ -13,7 +13,6 @@ import {
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { repeat } from "lit/directives/repeat";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
@@ -75,10 +74,6 @@ 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___";
@@ -158,14 +153,6 @@ 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?: (
| {
@@ -191,8 +178,6 @@ 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()
@@ -201,8 +186,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _items: PickerComboBoxItem[] = [];
@state() private _plainList = false;
@state() private _selectedSection?: string;
public setFieldValue(value: string) {
@@ -212,11 +195,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
protected get scrollableElement(): HTMLElement | null {
return this._listElement ?? null;
}
private get _listElement(): HTMLElement | undefined {
return this._plainList ? this._plainListElement : this.virtualizerElement;
return this.virtualizerElement as HTMLElement | null;
}
@state() private _sectionTitle?: string;
@@ -242,10 +221,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();
}
}
@@ -259,16 +238,6 @@ 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() {
@@ -302,61 +271,36 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
`
: nothing
}
<div class="list-wrapper ${this._plainList ? "" : "virtualized"}">
${this._plainList ? this._renderPlainList() : this._renderVirtualList()}
<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>
${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;
@@ -620,7 +564,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._items = this._getItems();
// Reset scroll position when filter changes
this._resetListScroll();
this.virtualizerElement?.element(0)?.scrollIntoView();
}
private _registerKeyboardShortcuts() {
@@ -634,26 +578,6 @@ 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();
@@ -665,7 +589,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
* or fall back to the first item when searching (skipping section titles).
*/
private _initializeSelectedIndex(): void {
if (!this._items.length) {
if (!this.virtualizerElement?.items?.length) {
return;
}
const initialIndex = this._getInitialSelectedIndex();
@@ -675,11 +599,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
let index = initialIndex;
// Skip section titles (strings)
if (typeof this._items[index] === "string") {
if (typeof this.virtualizerElement.items[index] === "string") {
index += 1;
}
// Bounds check: ensure index is valid after skipping section title
if (index >= this._items.length) {
if (index >= this.virtualizerElement.items.length) {
return;
}
this._selectedItemIndex = index;
@@ -689,13 +613,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectNextItem = (ev?: KeyboardEvent) => {
ev?.stopPropagation();
ev?.preventDefault();
if (!this._listElement) {
if (!this.virtualizerElement) {
return;
}
this._searchFieldElement?.focus();
const items = this._items;
const items = this.virtualizerElement.items as PickerComboBoxItem[];
const maxItems = items.length - 1;
@@ -737,14 +661,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectPreviousItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (!this._listElement) {
if (!this.virtualizerElement) {
return;
}
if (this._selectedItemIndex > 0) {
const nextIndex = this._selectedItemIndex - 1;
const items = this._items;
const items = this.virtualizerElement.items as PickerComboBoxItem[];
if (!items[nextIndex]) {
return;
@@ -766,13 +690,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectFirstItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._listElement || !this._items.length) {
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
return;
}
const nextIndex = 0;
if (typeof this._items[nextIndex] === "string") {
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -783,13 +707,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectLastItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._listElement || !this._items.length) {
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
return;
}
const nextIndex = this._items.length - 1;
const nextIndex = this.virtualizerElement.items.length - 1;
if (typeof this._items[nextIndex] === "string") {
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -799,12 +723,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
};
private _scrollToSelectedItem = () => {
this._listElement?.querySelector(".selected")?.classList.remove("selected");
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._scrollRowIntoView(this._selectedItemIndex);
this.virtualizerElement
?.element(this._selectedItemIndex)
?.scrollIntoView({ block: "nearest" });
requestAnimationFrame(() => {
this._listElement
this.virtualizerElement
?.querySelector(`#list-item-${this._selectedItemIndex}`)
?.classList.add("selected");
});
@@ -821,10 +749,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _pickItem = (ev: KeyboardEvent, newTab: boolean) => {
ev.stopPropagation();
if (
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?.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.forEach((item, index) => {
(
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
).forEach((item, index) => {
if (typeof item !== "string" && !item.disabled) {
this._fireSelectedEvents(item.id, index, newTab);
}
@@ -842,14 +774,18 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
// if filter button is focused
ev.preventDefault();
const item = this._items[this._selectedItemIndex];
const item = this.virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
if (item && !item.disabled) {
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
}
};
private _resetSelectedItem() {
this._listElement?.querySelector(".selected")?.classList.remove("selected");
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._selectedItemIndex = -1;
}
@@ -857,11 +793,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
typeof item === "string" ? item : item?.id;
private _getInitialSelectedIndex() {
if (this._search || !this.value) {
if (!this.virtualizerElement || this._search || !this.value) {
return 0;
}
const index = this._items.findIndex(
const index = this.virtualizerElement.items.findIndex(
(item) =>
typeof item !== "string" &&
(item as PickerComboBoxItem).id === this.value
@@ -884,7 +820,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
flex-direction: column;
padding-top: var(--ha-space-4);
flex: 1;
min-height: 0;
}
:host([clearable]) {
@@ -916,41 +851,23 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
}
.list-wrapper {
.virtualizer-wrapper {
position: relative;
flex: 0 1 auto;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* 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 {
lit-virtualizer {
flex: 1;
}
lit-virtualizer,
.plain-list {
flex: 1;
min-height: 0;
}
.plain-list {
overflow: auto;
}
lit-virtualizer:focus-visible,
.plain-list:focus-visible {
lit-virtualizer:focus-visible {
outline: none;
}
.scrolled {
lit-virtualizer.scrolled {
border-top: 1px solid var(--ha-color-border-neutral-quiet);
}
+378 -72
View File
@@ -1,10 +1,33 @@
import { html, LitElement, type TemplateResult } from "lit";
import { 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-media-item-picker";
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 { fireEvent } from "../../common/dom/fire_event";
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-selector-media")
export class HaMediaSelector extends LitElement {
@@ -12,8 +35,7 @@ export class HaMediaSelector extends LitElement {
@property({ attribute: false }) public selector!: MediaSelector;
@property({ attribute: false }) public value?:
MediaSelectorValue | MediaSelectorValue[];
@property({ attribute: false }) public value?: MediaSelectorValue;
@property() public label?: string;
@@ -27,83 +49,367 @@ export class HaMediaSelector extends LitElement {
filter_entity?: string | string[];
};
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>`;
private _contextEntities: string[] | undefined;
private get _hasAccept(): boolean {
return !!this.selector?.media?.accept?.length;
}
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;
}
willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("context")) {
if (!this._hasAccept) {
this._contextEntities = ensureArray(this.context?.filter_entity);
}
}
fireEvent(this, "value-changed", { value: newValue });
};
}
protected render() {
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;
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>`;
}
return this._renderPicker(
this.helper,
Array.isArray(this.value) ? this.value[0] : this.value
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]
);
}
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,7 +13,6 @@ import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
import "../ha-md-list";
@@ -238,14 +237,10 @@ export class HaObjectSelector extends LitElement {
private async _addItem(ev) {
ev.stopPropagation();
const schema = this._schema(this.selector);
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
schema,
data: computeInitialHaFormData(schema, {
skipUnsupportedSelectors: true,
}),
schema: this._schema(this.selector),
data: {},
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
@@ -298,7 +293,7 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: "" });
fireEvent(this, "value-changed", { value: undefined });
return;
}
@@ -99,10 +99,6 @@ const SELECTOR_SCHEMAS = {
},
},
},
{
name: "multiple",
selector: { boolean: {} },
},
] as const,
number: [
{
@@ -1,46 +0,0 @@
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;
}
}
+2 -3
View File
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { Selector, SelectorType } from "../../data/selector";
import type { Selector } from "../../data/selector";
import {
handleLegacyDeviceSelector,
handleLegacyEntitySelector,
@@ -49,7 +49,6 @@ 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"),
@@ -71,7 +70,7 @@ const LOAD_ELEMENTS = {
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
} satisfies Record<SelectorType, () => Promise<unknown>>;
};
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
+6 -2
View File
@@ -12,6 +12,7 @@ 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";
@@ -295,7 +296,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("alwaysExpand")) {
this.toggleAttribute("expanded", this.alwaysExpand);
toggleAttribute(this, "expanded", this.alwaysExpand);
}
if (!changedProps.has("hass")) {
return;
@@ -664,7 +665,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
background-color: var(--sidebar-background-color);
width: 100%;
box-sizing: border-box;
@@ -894,6 +897,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
.menu ha-icon-button {
-webkit-transform: scaleX(var(--scale-direction));
transform: scaleX(var(--scale-direction));
}
-221
View File
@@ -1,221 +0,0 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { 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;
}
}
+107 -135
View File
@@ -9,8 +9,7 @@ import {
} from "../resources/theme/color/color.globals";
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
import "./ha-button";
import "./item/ha-list-item-base";
import "./list/ha-list-base";
import "./ha-settings-row";
import "./ha-theme-picker";
import "./input/ha-input";
import "./radio/ha-radio-group";
@@ -26,7 +25,6 @@ export interface ThemeSettingsLabels {
autoMode?: string;
lightMode?: string;
darkMode?: string;
colors?: string;
primaryColor?: string;
accentColor?: string;
reset?: string;
@@ -44,7 +42,7 @@ export class HaThemeSettings extends LitElement {
@property() public heading?: string;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@@ -64,121 +62,98 @@ 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-list-base>
<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
}
${
this.showThemePicker
? html`
<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>
<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>
`
: nothing
}
${
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
</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"
}
`
: nothing
}
</ha-list-base>
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
}
`;
}
@@ -256,35 +231,32 @@ export class HaThemeSettings extends LitElement {
a {
color: var(--primary-color);
}
ha-list-base {
--ha-row-item-padding-block: var(--ha-space-2);
.inputs {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: 0 var(--ha-space-3);
}
ha-theme-picker {
min-width: 150px;
ha-radio-group {
display: flex;
justify-content: center;
margin-inline-end: var(--ha-space-3);
}
.color-pickers {
display: flex;
gap: var(--ha-space-2);
}
.color-pickers ha-input {
min-width: 150px;
flex: 1;
}
: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);
align-items: center;
flex-grow: 1;
}
ha-input {
min-width: 75px;
flex-grow: 1;
margin: 0 var(--ha-space-1);
}
ha-theme-picker {
display: block;
width: 100%;
}
`;
}
-18
View File
@@ -58,24 +58,6 @@ class HaEntityMarker extends LitElement {
`;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("keydown", this._handleKeydown);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeydown);
}
// The map engines make the marker a focusable button
private _handleKeydown = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._badgeTap(ev);
}
};
private _badgeTap(ev: Event) {
ev.stopPropagation();
if (this.entityId) {
+2 -3
View File
@@ -3,6 +3,7 @@ import type {
DivIcon,
DragEndEvent,
LatLng,
LatLngExpression,
Marker,
MarkerOptions,
} from "leaflet";
@@ -12,7 +13,6 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { MapLatLng } from "../../common/map/map-engine";
import type { ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
@@ -87,7 +87,7 @@ export class HaLocationsEditor extends LitElement {
}
public fitBounds(
boundingbox: MapLatLng[],
boundingbox: LatLngExpression[],
options?: { zoom?: number; pad?: number }
) {
this.map.fitBounds(boundingbox, options);
@@ -126,7 +126,6 @@ export class HaLocationsEditor extends LitElement {
protected render(): TemplateResult {
return html`
<ha-map
engine="leaflet"
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
File diff suppressed because it is too large Load Diff
@@ -564,73 +564,75 @@ export class HaTargetPickerItemRow extends LitElement {
this.primaryEntitiesOnly
);
let referencedAreas = entries.referenced_areas;
const hiddenAreaIds: string[] = [];
if (this.type === "floor" || this.type === "label") {
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) {
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);
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"
) {
referencedDevices = referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
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);
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;
});
);
}
const referencedEntities = entries.referenced_entities.filter(
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
@@ -645,9 +647,9 @@ export class HaTargetPickerItemRow extends LitElement {
(this.type === "area" && entity.area_id === this.itemId) ||
(this.type === "floor" &&
entity.area_id &&
referencedAreas.includes(entity.area_id)) ||
entries.referenced_areas.includes(entity.area_id)) ||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
referencedDevices.includes(entity.device_id || "")
entries.referenced_devices.includes(entity.device_id || "")
) {
return entityRegMeetsFilter(
entity,
@@ -662,12 +664,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
);
this._entries = {
...entries,
referenced_areas: referencedAreas,
referenced_devices: referencedDevices,
referenced_entities: referencedEntities,
};
this._entries = entries;
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to extract target", e);
+1 -1
View File
@@ -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}`,
+2 -1
View File
@@ -15,6 +15,7 @@ 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";
@@ -869,7 +870,7 @@ export class HaAutomationTracer extends LitElement {
this.shadowRoot!.querySelectorAll<HaTimeline>(
"ha-timeline[data-path]"
).forEach((el) => {
el.toggleAttribute("selected", this.selectedPath === el.dataset.path);
toggleAttribute(el, "selected", this.selectedPath === el.dataset.path);
if (!this.allowPick || el.tabIndex === 0) {
return;
}

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