mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-16 05:49:19 +00:00
Compare commits
137
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e20c643dd4 | ||
|
|
94fdea6f00 | ||
|
|
37de8c4b15 | ||
|
|
841729af93 | ||
|
|
e3f92a6df3 | ||
|
|
a781228706 | ||
|
|
f9e02b628f | ||
|
|
af8a80f0fe | ||
|
|
c01a140751 | ||
|
|
e50eaaa865 | ||
|
|
cd8b975a38 | ||
|
|
5dd3a4b662 | ||
|
|
e5fa96465c | ||
|
|
79467b51b1 | ||
|
|
c3cc2217bf | ||
|
|
6e6e4d4c4d | ||
|
|
f6cbe49d81 | ||
|
|
1e9592a0b8 | ||
|
|
b2b0491c99 | ||
|
|
9bffae3ee4 | ||
|
|
a287aacd98 | ||
|
|
b29b0bd083 | ||
|
|
ae8aa3813b | ||
|
|
770caf62f7 | ||
|
|
8fed3ac0c8 | ||
|
|
98f5bdd703 | ||
|
|
8e13ba872d | ||
|
|
37433c787f | ||
|
|
dc7ee8dc5a | ||
|
|
ddc99501af | ||
|
|
5ec87269dc | ||
|
|
53cf986491 | ||
|
|
0388cb8cd0 | ||
|
|
cfefa39b84 | ||
|
|
5a907614af | ||
|
|
c325392e79 | ||
|
|
241b8c5771 | ||
|
|
06e55141f5 | ||
|
|
f6751a8c44 | ||
|
|
1fb138337a | ||
|
|
50df620a01 | ||
|
|
e581172474 | ||
|
|
59f16eeca2 | ||
|
|
1cda8ee42d | ||
|
|
87a4a70352 | ||
|
|
046cf4de14 | ||
|
|
d0d5b93106 | ||
|
|
3f2c0dae1b | ||
|
|
5dea28504e | ||
|
|
8e1c4e93cf | ||
|
|
5513abbe8f | ||
|
|
a9862a8f7c | ||
|
|
d23314f4ff | ||
|
|
fcc5b381d3 | ||
|
|
564205a3f3 | ||
|
|
86fb936d14 | ||
|
|
497b7fce49 | ||
|
|
251aac7e2e | ||
|
|
8d2106ccc8 | ||
|
|
3de2637da7 | ||
|
|
36d048c8ab | ||
|
|
4d7d5231d6 | ||
|
|
b5709a56b0 | ||
|
|
1b834303d0 | ||
|
|
b455a34998 | ||
|
|
d84ef6b1bb | ||
|
|
10ae5c1dd6 | ||
|
|
74967ef6ee | ||
|
|
46de23727f | ||
|
|
dc5d9f48bb | ||
|
|
3d3cdabfe7 | ||
|
|
4b1f12c0ef | ||
|
|
a9d7712fb2 | ||
|
|
a03efc1c5d | ||
|
|
9f7c353ad0 | ||
|
|
350fae4107 | ||
|
|
e09c4084b7 | ||
|
|
946722daa2 | ||
|
|
3b038d5372 | ||
|
|
6b7d387175 | ||
|
|
8a4202ea19 | ||
|
|
2eaf1112f2 | ||
|
|
d30ff07394 | ||
|
|
5616b52979 | ||
|
|
4219288701 | ||
|
|
09b221cf45 | ||
|
|
89a467cac7 | ||
|
|
c29b693038 | ||
|
|
7ab178d045 | ||
|
|
538523c58b | ||
|
|
136cf0c8bf | ||
|
|
920c858056 | ||
|
|
3d2a4bfa04 | ||
|
|
e2c9b2cb79 | ||
|
|
d38ee185bf | ||
|
|
7e10f50419 | ||
|
|
070fd96d6c | ||
|
|
156840b6ff | ||
|
|
5ff7a05ec5 | ||
|
|
c3643555ab | ||
|
|
ae2c296272 | ||
|
|
1db1c8024e | ||
|
|
2f003ca999 | ||
|
|
b1d8a21aaa | ||
|
|
cde2e66229 | ||
|
|
71d5500ed8 | ||
|
|
462321a3c8 | ||
|
|
afc0c36dd9 | ||
|
|
7f86074e07 | ||
|
|
4b9e47079c | ||
|
|
e3b9c6e143 | ||
|
|
221f184cb1 | ||
|
|
27cbbdb83f | ||
|
|
337e88560e | ||
|
|
9c96c56558 | ||
|
|
27e0c4492c | ||
|
|
80889c3cea | ||
|
|
cbbaf51e75 | ||
|
|
9778a54305 | ||
|
|
b9125537c0 | ||
|
|
fd6017a963 | ||
|
|
e0f5d6d6b3 | ||
|
|
193ab1b424 | ||
|
|
4a3b6db411 | ||
|
|
46271d0435 | ||
|
|
d4bc02c86b | ||
|
|
a12ae9a8ed | ||
|
|
6095787294 | ||
|
|
127702b96f | ||
|
|
56a6c5d9fb | ||
|
|
3b686dc3b9 | ||
|
|
c5e8dea750 | ||
|
|
29cd46eb9e | ||
|
|
92b3081896 | ||
|
|
527319ab5e | ||
|
|
8d3877c388 | ||
|
|
66b9fbd4bf |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ha-frontend-review
|
||||
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
|
||||
description: Home Assistant frontend PR and review guidance, including implementation quality, existing review feedback, established design patterns, and the authority of UI/UX evidence. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
|
||||
---
|
||||
|
||||
# HA Frontend Review
|
||||
@@ -92,9 +92,28 @@ Configuration and props:
|
||||
- Keep APIs extensible without adding speculative abstractions.
|
||||
- Validate configuration before applying changes.
|
||||
|
||||
## UI/UX Evidence
|
||||
|
||||
For user-facing changes, establish the existing design context as part of frontend review:
|
||||
|
||||
- Treat an applicable gallery specification as the authoritative repository source for the documented component or interaction. Inspect its written guidance and demos when present.
|
||||
- Note relevant production designs, approved designs, and shared `ha-*` components so the implementation can also be compared with established frontend behaviour.
|
||||
- Require the applicable gallery documentation or demo to change when the implementation intentionally changes behaviour documented there.
|
||||
- Prefer an established input layout or appropriate shared component over a raw input or ad hoc control.
|
||||
- Treat explicit UI/UX approval, current repository guidance, and direct human or workflow instructions as stronger evidence than inference.
|
||||
- Treat linked tasks as evidence of the problem and stated requirements, not automatic UI/UX approval.
|
||||
- When the direction remains uncertain, search frontend pull requests that carry or previously carried **Needs UX**. Inspect their label history, comments, and reviews rather than relying on the current label alone.
|
||||
- Judge relevant feedback by its content and surrounding discussion. When GitHub provides `author_association`, prioritise feedback marked `MEMBER`.
|
||||
- Prefer recent feedback about similar interactions or components. Label removal can show that a workflow gate moved on, but does not prove approval without the surrounding discussion.
|
||||
- Summarise the applicable guidance instead of maintaining a reviewer list or treating one historical decision as a permanent rule.
|
||||
|
||||
## Review Flow
|
||||
|
||||
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
|
||||
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
|
||||
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: ha-frontend-ux-readiness
|
||||
description: Home Assistant frontend UI/UX readiness routing for new cards, badges, screens, and interaction patterns. Use it with `ha-frontend-review` to decide whether frontend review is enough or further UI/UX input is needed. Skip it for PRs already carrying Needs UX and for fixes or technical details that preserve an established experience.
|
||||
---
|
||||
|
||||
# HA Frontend UI/UX Readiness
|
||||
|
||||
Use the design context and evidence gathered through `ha-frontend-review` to decide whether a change's UI/UX direction is settled and whether further UI/UX review should be recommended or is explicitly required. This skill routes the result; it does not repeat or replace implementation, accessibility, or frontend quality review.
|
||||
|
||||
## Early Exit
|
||||
|
||||
If the PR already carries the **Needs UX** label, reviewers have already decided to ask designers or UX reviewers for guidance. Do not use this skill to make that recommendation again.
|
||||
|
||||
## Triage
|
||||
|
||||
1. Apply `ha-frontend-review` and record its UI/UX evidence.
|
||||
- Include the applicable gallery specification, production or approved designs, shared `ha-*` components, task requirements, historical feedback, and direct instructions.
|
||||
- Keep the implementation review separate from this readiness decision.
|
||||
2. Classify the user-facing decision.
|
||||
- Existing-feature fixes and technical details normally remain with frontend review when they preserve the established experience.
|
||||
- New cards, badges, screens, and interaction patterns normally need this readiness check.
|
||||
3. Decide whether the evidence gathered during frontend review settles the direction.
|
||||
- Frontend maintainer guidance, explicit UI/UX approval, current guidance, an established pattern, or a task that settles the material visual and interaction choices can settle the direction.
|
||||
- Insufficient or conflicting evidence leaves the direction unresolved.
|
||||
4. Select exactly one route below and state which frontend-review evidence supports it.
|
||||
|
||||
## Routes
|
||||
|
||||
- **Frontend review sufficient:** The change preserves the established experience, or the evidence settles the material visual and interaction choices. Cite that evidence and continue with `ha-frontend-review`.
|
||||
- **UI/UX review recommended:** A new user experience has material design choices that remain unresolved. Identify the decision that needs input and recommend UI/UX review.
|
||||
- **UI/UX review required:** A workflow rule or direct human instruction explicitly requires UI/UX review. Cite that evidence. A new design, task link, or lack of evidence is not enough on its own.
|
||||
|
||||
Frontend maintainer guidance, explicit UI/UX approval, established patterns, and task requirements inform the route. A task can settle requirements without settling the UI/UX. Recommend UI/UX review when important design choices remain unresolved.
|
||||
|
||||
## Reporting
|
||||
|
||||
Keep the result concise and use this structure:
|
||||
|
||||
- **Route:** Frontend review sufficient, UI/UX review recommended, or UI/UX review required
|
||||
- **Established patterns:** relevant gallery specifications, production designs, and shared `ha-*` components for the next reviewer to compare
|
||||
- **Supporting evidence:** approval, guidance, established patterns, task requirements, direct instructions, or the evidence gap or conflict
|
||||
- **Next action:** the review action implied by the route
|
||||
|
||||
Separate whether the UI/UX direction is settled from whether the implementation is correct.
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Fails the check when a pull request carries a label that blocks merging, and
|
||||
// writes the outcome to the job summary. Invoked from the `check` job in
|
||||
// .github/workflows/blocking-labels.yaml via actions/github-script:
|
||||
//
|
||||
// const { default: checkBlockingLabels } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
|
||||
// await checkBlockingLabels({ github, context, core });
|
||||
|
||||
export default async function checkBlockingLabels({ context, core }) {
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(
|
||||
":white_check_mark: Pull Request is clear to merge after review",
|
||||
2
|
||||
)
|
||||
.addRaw(
|
||||
"This Pull Request is not blocked by any labels which prevent it from being merged."
|
||||
)
|
||||
.write();
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Blocking labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkBlockingLabels } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
|
||||
);
|
||||
await checkBlockingLabels({ github, context, core });
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -47,7 +47,8 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
|
||||
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
|
||||
- `ha-frontend-review`: PR template use, existing review feedback, review checklist, recurring issues, and UI/UX evidence including gallery specifications.
|
||||
- `ha-frontend-ux-readiness`: UI/UX routing that complements frontend review for new user experiences.
|
||||
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
|
||||
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
|
||||
|
||||
|
||||
@@ -34,6 +34,14 @@ In production, the following responsibilities are added:
|
||||
|
||||
- Minify HTML
|
||||
- Bundle multiple imports so that the browser can fetch less files
|
||||
- Generate a second version that is ES5 compatible
|
||||
- Generate a second version that is compatible with older browsers (legacy build)
|
||||
|
||||
Configuration for all these steps are specified in [bundle.js](bundle.js).
|
||||
|
||||
## Auditing browser support changes
|
||||
|
||||
`node build-scripts/list-plugins-and-polyfills.js` prints, per browserslist
|
||||
environment (modern/legacy), the Babel transforms preset-env enables and the
|
||||
Core-JS polyfills that may be injected — as collapsible markdown ready to
|
||||
paste into a PR. Use it to show the bundle impact when changing
|
||||
`.browserslistrc` or the Babel configuration.
|
||||
|
||||
@@ -121,7 +121,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
ignoreModuleNotFound: true,
|
||||
},
|
||||
],
|
||||
// Import helpers and regenerator from runtime package.
|
||||
// Import helpers from runtime package.
|
||||
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
|
||||
// corejs3 polyfill provider above otherwise redirects them to the
|
||||
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
|
||||
@@ -155,8 +155,6 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
"proxy-polyfill",
|
||||
"unfetch",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
|
||||
16: [11, 0, 0],
|
||||
17: [12, 0, 0],
|
||||
18: [13, 0, 0],
|
||||
26: [26, 0, 0],
|
||||
26: [14, 6, 0],
|
||||
};
|
||||
|
||||
const getCommonTemplateVars = () => {
|
||||
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
|
||||
...htmlMinifierOptions,
|
||||
conservativeCollapse: false,
|
||||
minifyJS: terserOptions({
|
||||
latestBuild: false, // Shared scripts should be ES5
|
||||
latestBuild: false, // Shared scripts must satisfy the legacy targets
|
||||
isTestBuild: true, // Don't need source maps
|
||||
}),
|
||||
}).then((wrapped) =>
|
||||
|
||||
@@ -42,37 +42,6 @@ function copyMdiIcons(staticDir) {
|
||||
fs.copySync(polyPath("build/mdi"), staticPath("mdi"));
|
||||
}
|
||||
|
||||
function copyPolyfills(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
|
||||
// For custom panels using ES5 builds that don't use Babel 7+
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
|
||||
// Web Component polyfills and adapters
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js.map"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
// Lit polyfill support
|
||||
fs.copySync(
|
||||
npmPath("lit/polyfill-support.js"),
|
||||
path.join(staticPath("polyfills/"), "lit-polyfill-support.js")
|
||||
);
|
||||
|
||||
// dialog-polyfill css
|
||||
copyFileDir(
|
||||
npmPath("dialog-polyfill/dialog-polyfill.css"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
}
|
||||
|
||||
function copyFonts(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Local fonts
|
||||
@@ -141,7 +110,6 @@ gulp.task("copy-static-app", async () => {
|
||||
const staticDir = paths.app_output_static;
|
||||
// Basic static files
|
||||
fs.copySync(polyPath("public"), paths.app_output_root);
|
||||
copyPolyfills(staticDir);
|
||||
copyFonts(staticDir);
|
||||
copyTranslations(staticDir);
|
||||
copyLocaleData(staticDir);
|
||||
@@ -163,7 +131,6 @@ gulp.task("copy-static-demo", async () => {
|
||||
);
|
||||
// Copy demo static files
|
||||
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
|
||||
copyPolyfills(paths.demo_output_static);
|
||||
await copyMapPanel(paths.demo_output_static);
|
||||
copyFonts(paths.demo_output_static);
|
||||
copyTranslations(paths.demo_output_static);
|
||||
@@ -176,7 +143,6 @@ gulp.task("copy-static-cast", async () => {
|
||||
fs.copySync(polyPath("public/static"), paths.cast_output_static);
|
||||
// Copy cast static files
|
||||
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
|
||||
copyPolyfills(paths.cast_output_static);
|
||||
await copyMapPanel(paths.cast_output_static);
|
||||
copyFonts(paths.cast_output_static);
|
||||
copyTranslations(paths.cast_output_static);
|
||||
@@ -223,7 +189,6 @@ gulp.task("copy-static-e2e-test-app", async () => {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
await copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
|
||||
@@ -25,7 +25,7 @@ gulp.task("gen-sensor-entity-constants", async () => {
|
||||
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
|
||||
const deviceClassUnits = data.device_class_units ?? {};
|
||||
const convertibleClassUnits = data.convertible_units ?? {};
|
||||
const stateClasses = [...(data.state_classes ?? [])].sort();
|
||||
const stateClasses = data.state_classes ?? [];
|
||||
const stateClassUnits = data.state_class_units ?? {};
|
||||
if (
|
||||
!numericDeviceClasses.length ||
|
||||
|
||||
@@ -1,38 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
|
||||
|
||||
import { version as babelVersion } from "@babel/core";
|
||||
import presetEnv from "@babel/preset-env";
|
||||
import compilationTargets from "@babel/helper-compilation-targets";
|
||||
import { transformSync } from "@babel/core";
|
||||
import compilationTargets, {
|
||||
getInclusionReasons,
|
||||
} from "@babel/helper-compilation-targets";
|
||||
import coreJSCompat from "core-js-compat";
|
||||
import { logPlugin } from "@babel/preset-env/lib/debug.js";
|
||||
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
|
||||
import { babelOptions } from "./bundle.cjs";
|
||||
|
||||
const detailsOpen = (heading) =>
|
||||
`<details>\n<summary><h4>${heading}</h4></summary>\n`;
|
||||
const detailsClose = "</details>\n";
|
||||
|
||||
const dummyAPI = {
|
||||
version: babelVersion,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
assertVersion: () => {},
|
||||
caller: (callback) =>
|
||||
callback({
|
||||
name: "Dummy Bundler",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
}),
|
||||
targets: () => ({}),
|
||||
// Copied from @babel/preset-env's internal `logPlugin`, which Babel 8 no
|
||||
// longer exposes (the package rolls up into lib/index.js and exports nothing
|
||||
// but the preset). Prints an item with the targets that require it.
|
||||
const logPlugin = (item, targetVersions, list) => {
|
||||
const filteredList = getInclusionReasons(item, targetVersions, list);
|
||||
const support = list[item];
|
||||
if (!support) {
|
||||
console.log(` ${item}`);
|
||||
return;
|
||||
}
|
||||
let formattedTargets = `{`;
|
||||
let first = true;
|
||||
for (const target of Object.keys(filteredList)) {
|
||||
if (!first) formattedTargets += `,`;
|
||||
first = false;
|
||||
formattedTargets += ` ${target}`;
|
||||
if (support[target]) formattedTargets += ` < ${support[target]}`;
|
||||
}
|
||||
formattedTargets += ` }`;
|
||||
console.log(` ${item} ${formattedTargets}`);
|
||||
};
|
||||
|
||||
// Copied from babel-plugin-polyfill-corejs3's generated
|
||||
// corejs3ShippedProposalsList, which v1 no longer exposes (it is inlined in
|
||||
// the package's rolled-up bundle).
|
||||
const shippedProposalsList = new Set([
|
||||
"esnext.array.group",
|
||||
"esnext.array.group-to-map",
|
||||
"esnext.iterator.zip",
|
||||
"esnext.iterator.zip-keyed",
|
||||
"esnext.symbol.metadata",
|
||||
]);
|
||||
|
||||
// Generate filter function based on proposal/method inputs
|
||||
// Copied and adapted from babel-plugin-polyfill-corejs3/esm/index.mjs
|
||||
const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
if (proposals || method === "entry-global") return true;
|
||||
if (shippedProposals && shippedPolyfills.default.has(name)) {
|
||||
if (shippedProposals && shippedProposalsList.has(name)) {
|
||||
return true;
|
||||
}
|
||||
if (name.startsWith("esnext.")) {
|
||||
@@ -47,7 +64,9 @@ const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
for (const buildType of ["Modern", "Legacy"]) {
|
||||
const browserslistEnv = buildType.toLowerCase();
|
||||
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
|
||||
const presetEnvOpts = babelOpts.presets[0][1];
|
||||
const presetEnvOpts = babelOpts.presets.find(
|
||||
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
|
||||
)?.[1];
|
||||
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
|
||||
// removed preset-env's `useBuiltIns`), so read its options here.
|
||||
const corejsOpts = babelOpts.plugins.find(
|
||||
@@ -55,22 +74,38 @@ for (const buildType of ["Modern", "Legacy"]) {
|
||||
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
|
||||
)?.[1];
|
||||
|
||||
// Invoking preset-env in debug mode will log the included plugins
|
||||
// Transforming an empty file with preset-env in debug mode logs the included
|
||||
// plugins. The caller declares the same capabilities babel-loader does, so
|
||||
// plugins gated on bundler support (e.g. transform-export-namespace-from)
|
||||
// match the build.
|
||||
presetEnvOpts.debug = true;
|
||||
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
|
||||
presetEnv.default(dummyAPI, {
|
||||
...presetEnvOpts,
|
||||
browserslistEnv,
|
||||
debug: true,
|
||||
transformSync("", {
|
||||
...babelOpts,
|
||||
configFile: false,
|
||||
filename: "audit.js",
|
||||
caller: {
|
||||
name: "list-plugins-and-polyfills",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
},
|
||||
});
|
||||
console.log(detailsClose);
|
||||
|
||||
// Manually log the Core-JS polyfills using the same technique
|
||||
if (corejsOpts) {
|
||||
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
|
||||
const targets = compilationTargets.default(babelOpts?.targets, {
|
||||
const targets = compilationTargets(babelOpts.targets, {
|
||||
browserslistEnv,
|
||||
});
|
||||
const polyfillList = coreJSCompat({ targets }).list.filter(
|
||||
// `version` limits the list to modules the installed core-js ships,
|
||||
// mirroring the provider's own filtering.
|
||||
const polyfillList = coreJSCompat({
|
||||
targets,
|
||||
version: corejsOpts.version,
|
||||
}).list.filter(
|
||||
polyfillFilter(
|
||||
corejsOpts.method,
|
||||
corejsOpts.proposals,
|
||||
|
||||
@@ -345,10 +345,7 @@ const createRspackConfig = ({
|
||||
"lit/directives/join$": "lit/directives/join.js",
|
||||
"lit/directives/repeat$": "lit/directives/repeat.js",
|
||||
"lit/directives/live$": "lit/directives/live.js",
|
||||
"lit/directives/keyed$": latestBuild
|
||||
? "lit/directives/keyed.js"
|
||||
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
|
||||
"lit/polyfill-support$": "lit/polyfill-support.js",
|
||||
"lit/directives/keyed$": "lit/directives/keyed.js",
|
||||
"@lit-labs/virtualizer/layouts/grid":
|
||||
"@lit-labs/virtualizer/layouts/grid.js",
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
saveTokens,
|
||||
} from "../../../../src/common/auth/token_storage";
|
||||
import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-icon";
|
||||
import "../../../../src/components/ha-list";
|
||||
@@ -197,8 +196,7 @@ class HcCast extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
toggleAttribute(
|
||||
this,
|
||||
this.toggleAttribute(
|
||||
"hide-icons",
|
||||
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { connectivityEntities } from "../stubs/connectivity/fixtures";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
@@ -53,6 +54,9 @@ export const setDemoConfig = async (
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
// Replaced the whole state map above, so the entities that do not belong to a
|
||||
// demo config have to be added back.
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Let the new registries and entities reach the dashboard before saving the
|
||||
// config, so dashboard strategies generate against them
|
||||
|
||||
+11
-1
@@ -7,6 +7,12 @@ import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import {
|
||||
connectivityCommands,
|
||||
connectivityComponents,
|
||||
connectivityEntities,
|
||||
connectivityEntityRegistryEntries,
|
||||
} from "./stubs/connectivity/fixtures";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
@@ -60,6 +66,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
...connectivityCommands,
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -87,6 +94,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
...connectivityComponents,
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -99,7 +107,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
mockLovelace(hass, localizePromise);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass);
|
||||
mockTranslations(hass, localizePromise);
|
||||
mockHistory(hass);
|
||||
mockRecorder(hass);
|
||||
mockTodo(hass);
|
||||
@@ -172,9 +180,11 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
...connectivityEntityRegistryEntries,
|
||||
]);
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Once config is loaded AND localize, set registries, entities and theme.
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mockBlueprint } from "./blueprint";
|
||||
import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockConnectivity } from "./connectivity";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
@@ -26,6 +27,7 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockCloud(hass);
|
||||
mockConfig(hass);
|
||||
mockConfigEntries(hass);
|
||||
mockConnectivity(hass);
|
||||
mockDeviceAutomation(hass);
|
||||
mockEntitySources(hass);
|
||||
mockBlueprint(hass);
|
||||
@@ -43,4 +45,10 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
hass.mockWS("llm/api/list", () => ({
|
||||
apis: [
|
||||
{ id: "assist", name: "Assist" },
|
||||
{ id: "music_assistant", name: "Music Assistant" },
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
|
||||
import type { IntegrationType } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityConfigEntries } from "./connectivity/fixtures";
|
||||
|
||||
const baseEntry = {
|
||||
source: "user",
|
||||
@@ -80,6 +81,17 @@ export const demoConfigEntries: {
|
||||
title: "Comfort level",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-mcp-server",
|
||||
domain: "mcp_server",
|
||||
title: "Assist, Music Assistant",
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
...connectivityConfigEntries,
|
||||
];
|
||||
|
||||
const filterEntries = (filters?: {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import { configEntry, device } from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const LOCAL_SOURCE = "00:1A:7D:DA:71:11";
|
||||
export const PROXY_SOURCE = "E8:DB:84:A1:C2:30";
|
||||
export const SHED_SOURCE = "A4:CF:12:9B:44:70";
|
||||
|
||||
const ADAPTER_ENTRY_ID = "mock-bluetooth";
|
||||
const PROXY_LIVING_ENTRY_ID = "mock-bluetooth-proxy-living";
|
||||
const PROXY_SHED_ENTRY_ID = "mock-bluetooth-proxy-shed";
|
||||
|
||||
export const bluetoothFixtures: ConnectivityFixtures = {
|
||||
components: ["bluetooth"],
|
||||
commands: ["bluetooth/"],
|
||||
manifests: [manifest("bluetooth", "Bluetooth", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
ADAPTER_ENTRY_ID,
|
||||
"bluetooth",
|
||||
`hci0 (${LOCAL_SOURCE})`,
|
||||
{ source: "usb", supports_options: true }
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
PROXY_LIVING_ENTRY_ID,
|
||||
"bluetooth",
|
||||
"Living room proxy",
|
||||
{ source: "esphome" }
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(PROXY_SHED_ENTRY_ID, "bluetooth", "Shed proxy", {
|
||||
source: "esphome",
|
||||
}),
|
||||
},
|
||||
],
|
||||
// Adapters and proxies are matched to their scanner by the bluetooth
|
||||
// connection tuple, see ./mock.
|
||||
devices: [
|
||||
device(
|
||||
"bluetooth-hci0",
|
||||
"hci0",
|
||||
"Home Assistant",
|
||||
"Home Assistant Green",
|
||||
ADAPTER_ENTRY_ID,
|
||||
{ connections: [["bluetooth", LOCAL_SOURCE]] }
|
||||
),
|
||||
device(
|
||||
"bluetooth-proxy-living",
|
||||
"Living room proxy",
|
||||
"Espressif",
|
||||
"ESP32-C3",
|
||||
PROXY_LIVING_ENTRY_ID,
|
||||
{ area_id: "living_room", connections: [["bluetooth", PROXY_SOURCE]] }
|
||||
),
|
||||
device(
|
||||
"bluetooth-proxy-shed",
|
||||
"Shed proxy",
|
||||
"Espressif",
|
||||
"ESP32",
|
||||
PROXY_SHED_ENTRY_ID,
|
||||
{ connections: [["bluetooth", SHED_SOURCE]] }
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
BluetoothAllocationsData,
|
||||
BluetoothDeviceData,
|
||||
BluetoothScannerDetails,
|
||||
BluetoothScannerState,
|
||||
} from "../../../../../src/data/bluetooth";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
import { LOCAL_SOURCE, PROXY_SOURCE, SHED_SOURCE } from "./fixtures";
|
||||
|
||||
const SCANNERS: BluetoothScannerDetails[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
connectable: true,
|
||||
name: "hci0 (Home Assistant Green)",
|
||||
adapter: "hci0",
|
||||
scanner_type: "usb",
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
connectable: true,
|
||||
name: "Living room proxy",
|
||||
adapter: "esp32",
|
||||
scanner_type: "remote",
|
||||
},
|
||||
{
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
name: "Shed proxy",
|
||||
adapter: "esp32",
|
||||
scanner_type: "remote",
|
||||
},
|
||||
];
|
||||
|
||||
const SCANNER_STATES: BluetoothScannerState[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
adapter: "hci0",
|
||||
current_mode: "active",
|
||||
requested_mode: "active",
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
adapter: "esp32",
|
||||
current_mode: "active",
|
||||
requested_mode: "active",
|
||||
},
|
||||
{
|
||||
// A proxy configured for passive scanning: it reports advertisements but
|
||||
// cannot connect, matching its `connectable: false` scanner details.
|
||||
source: SHED_SOURCE,
|
||||
adapter: "esp32",
|
||||
current_mode: "passive",
|
||||
requested_mode: "passive",
|
||||
},
|
||||
];
|
||||
|
||||
const ALLOCATIONS: BluetoothAllocationsData[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
slots: 5,
|
||||
free: 3,
|
||||
allocated: ["A4:C1:38:11:22:33", "E7:2E:00:B1:9A:1C"],
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
slots: 3,
|
||||
free: 2,
|
||||
allocated: ["FC:58:FA:12:34:56"],
|
||||
},
|
||||
];
|
||||
|
||||
interface DemoAdvertisement {
|
||||
address: string;
|
||||
name: string;
|
||||
rssi: number;
|
||||
source: string;
|
||||
connectable?: boolean;
|
||||
tx_power?: number;
|
||||
manufacturer_data?: Record<number, string>;
|
||||
service_data?: Record<string, string>;
|
||||
service_uuids?: string[];
|
||||
}
|
||||
|
||||
const ADVERTISEMENTS: DemoAdvertisement[] = [
|
||||
{
|
||||
address: "A4:C1:38:11:22:33",
|
||||
name: "Govee H5075",
|
||||
rssi: -58,
|
||||
source: LOCAL_SOURCE,
|
||||
manufacturer_data: { 60552: "000104a10b64" },
|
||||
service_uuids: ["0000ec88-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "E7:2E:00:B1:9A:1C",
|
||||
name: "SwitchBot Meter",
|
||||
rssi: -71,
|
||||
source: LOCAL_SOURCE,
|
||||
service_data: { "0000fd3d-0000-1000-8000-00805f9b34fb": "5400648c14" },
|
||||
service_uuids: ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
|
||||
},
|
||||
{
|
||||
address: "FC:58:FA:12:34:56",
|
||||
name: "Xiaomi LYWSD03MMC",
|
||||
rssi: -64,
|
||||
source: PROXY_SOURCE,
|
||||
service_data: { "0000fe95-0000-1000-8000-00805f9b34fb": "3058590e" },
|
||||
},
|
||||
{
|
||||
address: "C4:7C:8D:6A:5B:20",
|
||||
name: "Flower care",
|
||||
rssi: -88,
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
service_uuids: ["0000fe95-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "D0:36:9A:7F:11:80",
|
||||
name: "Tile Mate",
|
||||
rssi: -79,
|
||||
source: PROXY_SOURCE,
|
||||
connectable: false,
|
||||
service_uuids: ["0000feed-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "5C:C7:C1:04:9E:2A",
|
||||
name: "Nut Find 3",
|
||||
rssi: -93,
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const buildAdvertisement = (
|
||||
advertisement: DemoAdvertisement
|
||||
): BluetoothDeviceData => ({
|
||||
address: advertisement.address,
|
||||
name: advertisement.name,
|
||||
rssi: advertisement.rssi,
|
||||
source: advertisement.source,
|
||||
connectable: advertisement.connectable ?? true,
|
||||
manufacturer_data: advertisement.manufacturer_data ?? {},
|
||||
service_data: advertisement.service_data ?? {},
|
||||
service_uuids: advertisement.service_uuids ?? [],
|
||||
tx_power: advertisement.tx_power ?? -59,
|
||||
time: Date.now() / 1000,
|
||||
raw: null,
|
||||
});
|
||||
|
||||
// Nudge the signal strength a little on every tick so the monitors and the
|
||||
// network map look alive without the rows jumping around.
|
||||
const jitter = (rssi: number) =>
|
||||
Math.max(-99, Math.min(-30, rssi + Math.round(Math.random() * 4) - 2));
|
||||
|
||||
export const mockBluetooth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("bluetooth/subscribe_scanner_details", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.({ add: SCANNERS }))
|
||||
);
|
||||
|
||||
hass.mockWS("bluetooth/subscribe_scanner_state", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => SCANNER_STATES.forEach((state) => onChange?.(state)))
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"bluetooth/subscribe_connection_allocations",
|
||||
(msg: { config_entry_id?: string }, _hass, onChange) =>
|
||||
emitInitial(() =>
|
||||
onChange?.(
|
||||
msg.config_entry_id
|
||||
? ALLOCATIONS.filter((a) => a.source === LOCAL_SOURCE)
|
||||
: ALLOCATIONS
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
hass.mockWS("bluetooth/subscribe_advertisements", (_msg, _hass, onChange) => {
|
||||
let advertisements = ADVERTISEMENTS;
|
||||
const stopInitial = emitInitial(() =>
|
||||
onChange?.({ add: advertisements.map(buildAdvertisement) })
|
||||
);
|
||||
const interval = window.setInterval(() => {
|
||||
advertisements = advertisements.map((advertisement) => ({
|
||||
...advertisement,
|
||||
rssi: jitter(advertisement.rssi),
|
||||
}));
|
||||
onChange?.({ change: advertisements.map(buildAdvertisement) });
|
||||
}, 5000);
|
||||
return () => {
|
||||
stopInitial();
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { bluetoothFixtures } from "./bluetooth/fixtures";
|
||||
import { infraredFixtures } from "./infrared/fixtures";
|
||||
import { matterFixtures } from "./matter/fixtures";
|
||||
import { mqttFixtures } from "./mqtt/fixtures";
|
||||
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
|
||||
import { serialFixtures } from "./serial/fixtures";
|
||||
import { tagsFixtures } from "./tags/fixtures";
|
||||
import { threadFixtures } from "./thread/fixtures";
|
||||
import type { ConnectivityFixtures } from "./types";
|
||||
import { zhaFixtures } from "./zha/fixtures";
|
||||
import { zwaveJsFixtures } from "./zwave_js/fixtures";
|
||||
|
||||
// Every integration reachable from Settings > Connectivity that has frontend
|
||||
// data to mock. Each owns its own fixtures, so they can be added and removed
|
||||
// one at a time.
|
||||
const INTEGRATIONS: ConnectivityFixtures[] = [
|
||||
bluetoothFixtures,
|
||||
serialFixtures,
|
||||
mqttFixtures,
|
||||
matterFixtures,
|
||||
infraredFixtures,
|
||||
zwaveJsFixtures,
|
||||
zhaFixtures,
|
||||
radioFrequencyFixtures,
|
||||
tagsFixtures,
|
||||
threadFixtures,
|
||||
];
|
||||
|
||||
const collect = <T>(
|
||||
pick: (fixtures: ConnectivityFixtures) => T[] | undefined
|
||||
) => INTEGRATIONS.flatMap((fixtures) => pick(fixtures) ?? []);
|
||||
|
||||
export const connectivityComponents = collect((f) => f.components);
|
||||
|
||||
export const connectivityCommands = collect((f) => f.commands);
|
||||
|
||||
export const connectivityConfigEntries = collect((f) => f.configEntries);
|
||||
|
||||
export const connectivityManifests = collect((f) => f.manifests);
|
||||
|
||||
export const connectivityDevices = collect((f) => f.devices);
|
||||
|
||||
export const connectivityEntityRegistryEntries = collect(
|
||||
(f) => f.entityRegistryEntries
|
||||
);
|
||||
|
||||
export const connectivityEntities = () =>
|
||||
INTEGRATIONS.flatMap((fixtures) => fixtures.entities?.() ?? []);
|
||||
|
||||
/** Backend translation resources, merged per category. */
|
||||
export const connectivityBackendTranslations = INTEGRATIONS.reduce<
|
||||
Record<string, Record<string, string>>
|
||||
>((resources, fixtures) => {
|
||||
for (const [category, keys] of Object.entries(
|
||||
fixtures.backendTranslations ?? {}
|
||||
)) {
|
||||
resources[category] = { ...resources[category], ...keys };
|
||||
}
|
||||
return resources;
|
||||
}, {});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ConfigEntry } from "../../../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type { EntityInput } from "../../../../src/fake_data/entities/types";
|
||||
|
||||
// Builders for the registry fixtures, so each integration only spells out what
|
||||
// makes its own entries different.
|
||||
|
||||
const BASE_CONFIG_ENTRY = {
|
||||
source: "user",
|
||||
state: "loaded" as const,
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
|
||||
export const configEntry = (
|
||||
entryId: string,
|
||||
domain: string,
|
||||
title: string,
|
||||
extra: Partial<ConfigEntry> = {}
|
||||
): ConfigEntry => ({
|
||||
...BASE_CONFIG_ENTRY,
|
||||
entry_id: entryId,
|
||||
domain,
|
||||
title,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const BASE_DEVICE = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
entry_type: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const device = (
|
||||
id: string,
|
||||
name: string,
|
||||
manufacturer: string,
|
||||
model: string,
|
||||
entryId: string,
|
||||
extra: Partial<DeviceRegistryEntry> = {}
|
||||
): DeviceRegistryEntry => ({
|
||||
...BASE_DEVICE,
|
||||
id,
|
||||
name,
|
||||
manufacturer,
|
||||
model,
|
||||
config_entries: [entryId],
|
||||
primary_config_entry: entryId,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const BASE_REGISTRY_ENTRY = {
|
||||
config_subentry_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
icon: null,
|
||||
labels: [] as string[],
|
||||
categories: {},
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
options: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const registryEntry = (
|
||||
entityId: string,
|
||||
deviceId: string,
|
||||
entryId: string,
|
||||
platform: string,
|
||||
name?: string
|
||||
): EntityRegistryEntry => ({
|
||||
...BASE_REGISTRY_ENTRY,
|
||||
entity_id: entityId,
|
||||
id: entityId,
|
||||
unique_id: entityId,
|
||||
device_id: deviceId,
|
||||
config_entry_id: entryId,
|
||||
platform,
|
||||
name: name ?? null,
|
||||
has_entity_name: name === undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* The demo's `addEntities` builds the display entity registry from the entity
|
||||
* inputs, so mirror each state's registry entry onto it. Panels count entities
|
||||
* per device and per integration.
|
||||
*/
|
||||
export const withRegistryLinks = (
|
||||
entries: EntityRegistryEntry[],
|
||||
states: Record<string, EntityInput>
|
||||
): EntityInput[] =>
|
||||
Object.values(states).map((state) => {
|
||||
const entry = entries.find(
|
||||
(candidate) => candidate.entity_id === state.entity_id
|
||||
);
|
||||
return entry
|
||||
? { ...state, device_id: entry.device_id!, platform: entry.platform }
|
||||
: state;
|
||||
});
|
||||
|
||||
export const minutesAgo = (minutes: number) =>
|
||||
new Date(Date.now() - minutes * 60000).toISOString();
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { mockBluetooth } from "./bluetooth/mock";
|
||||
import { mockMatter } from "./matter/mock";
|
||||
import { mockMqtt } from "./mqtt/mock";
|
||||
import { mockRadioFrequency } from "./radio_frequency/mock";
|
||||
import { mockSerial } from "./serial/mock";
|
||||
import { mockThread } from "./thread/mock";
|
||||
import { mockZha } from "./zha/mock";
|
||||
import { mockZwaveJs } from "./zwave_js/mock";
|
||||
|
||||
// The WebSocket mocks, code-split into the config panel chunk.
|
||||
const MOCKS = [
|
||||
mockBluetooth,
|
||||
mockSerial,
|
||||
mockMatter,
|
||||
mockMqtt,
|
||||
mockZwaveJs,
|
||||
mockZha,
|
||||
mockRadioFrequency,
|
||||
mockThread,
|
||||
];
|
||||
|
||||
export const mockConnectivity = (hass: MockHomeAssistant) => {
|
||||
MOCKS.forEach((mock) => mock(hass));
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
minutesAgo,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-broadlink";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"broadlink-living-room",
|
||||
"Living room blaster",
|
||||
"Broadlink",
|
||||
"RM4 pro",
|
||||
ENTRY_ID,
|
||||
{ area_id: "living_room" }
|
||||
),
|
||||
device(
|
||||
"broadlink-bedroom",
|
||||
"Bedroom blaster",
|
||||
"Broadlink",
|
||||
"RM mini 3",
|
||||
ENTRY_ID,
|
||||
{ area_id: "bedroom" }
|
||||
),
|
||||
];
|
||||
|
||||
// The infrared panel is entity driven: the proxy entities live in the
|
||||
// `infrared` domain while their registry platform stays the integration that
|
||||
// provides them.
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"infrared.living_room_blaster_emitter",
|
||||
"broadlink-living-room",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Emitter"
|
||||
),
|
||||
registryEntry(
|
||||
"infrared.living_room_blaster_receiver",
|
||||
"broadlink-living-room",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Receiver"
|
||||
),
|
||||
registryEntry(
|
||||
"infrared.bedroom_blaster_emitter",
|
||||
"broadlink-bedroom",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Emitter"
|
||||
),
|
||||
];
|
||||
|
||||
export const infraredFixtures: ConnectivityFixtures = {
|
||||
components: ["infrared"],
|
||||
manifests: [
|
||||
manifest("broadlink", "Broadlink", {
|
||||
integration_type: "hub",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{ type: "hub", entry: configEntry(ENTRY_ID, "broadlink", "RM4 pro") },
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
// A proxy entity's state is the timestamp it was last used.
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"infrared.living_room_blaster_emitter": {
|
||||
entity_id: "infrared.living_room_blaster_emitter",
|
||||
state: minutesAgo(12),
|
||||
attributes: {
|
||||
friendly_name: "Living room blaster Emitter",
|
||||
device_class: "emitter",
|
||||
},
|
||||
},
|
||||
"infrared.living_room_blaster_receiver": {
|
||||
entity_id: "infrared.living_room_blaster_receiver",
|
||||
state: minutesAgo(3),
|
||||
attributes: {
|
||||
friendly_name: "Living room blaster Receiver",
|
||||
device_class: "receiver",
|
||||
},
|
||||
},
|
||||
"infrared.bedroom_blaster_emitter": {
|
||||
entity_id: "infrared.bedroom_blaster_emitter",
|
||||
state: minutesAgo(1440),
|
||||
attributes: {
|
||||
friendly_name: "Bedroom blaster Emitter",
|
||||
device_class: "emitter",
|
||||
},
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
entity_component: {
|
||||
// The emitter is the default device class, stored under the "_" key.
|
||||
"component.infrared.entity_component._.name": "Emitter",
|
||||
"component.infrared.entity_component.receiver.name": "Receiver",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-matter";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"matter-kitchen-light",
|
||||
"Kitchen ceiling",
|
||||
"Nanoleaf",
|
||||
"Essentials A19",
|
||||
ENTRY_ID,
|
||||
{ area_id: "kitchen", sw_version: "3.5.7" }
|
||||
),
|
||||
device(
|
||||
"matter-side-door-lock",
|
||||
"Side door lock",
|
||||
"Aqara",
|
||||
"Smart Lock U100",
|
||||
ENTRY_ID,
|
||||
{ sw_version: "1.2.0" }
|
||||
),
|
||||
device("matter-office-plug", "Office plug", "Eve", "Energy", ENTRY_ID, {
|
||||
area_id: "office",
|
||||
sw_version: "3.2.0",
|
||||
}),
|
||||
device("matter-patio-sensor", "Patio sensor", "Eve", "Weather", ENTRY_ID, {
|
||||
area_id: "garden",
|
||||
sw_version: "3.2.1",
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"light.kitchen_ceiling",
|
||||
"matter-kitchen-light",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
registryEntry("lock.side_door", "matter-side-door-lock", ENTRY_ID, "matter"),
|
||||
registryEntry("switch.office_plug", "matter-office-plug", ENTRY_ID, "matter"),
|
||||
registryEntry(
|
||||
"sensor.office_plug_power",
|
||||
"matter-office-plug",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.patio_temperature",
|
||||
"matter-patio-sensor",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
];
|
||||
|
||||
export const matterFixtures: ConnectivityFixtures = {
|
||||
components: ["matter"],
|
||||
commands: ["matter/"],
|
||||
manifests: [manifest("matter", "Matter", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "matter", "Matter", {
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.kitchen_ceiling": {
|
||||
entity_id: "light.kitchen_ceiling",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen ceiling",
|
||||
supported_color_modes: ["color_temp"],
|
||||
color_mode: "color_temp",
|
||||
brightness: 204,
|
||||
},
|
||||
},
|
||||
"lock.side_door": {
|
||||
entity_id: "lock.side_door",
|
||||
state: "locked",
|
||||
attributes: { friendly_name: "Side door lock" },
|
||||
},
|
||||
"switch.office_plug": {
|
||||
entity_id: "switch.office_plug",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Office plug" },
|
||||
},
|
||||
"sensor.office_plug_power": {
|
||||
entity_id: "sensor.office_plug_power",
|
||||
state: "42.5",
|
||||
attributes: {
|
||||
friendly_name: "Office plug power",
|
||||
device_class: "power",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "W",
|
||||
},
|
||||
},
|
||||
"sensor.patio_temperature": {
|
||||
entity_id: "sensor.patio_temperature",
|
||||
state: "14.2",
|
||||
attributes: {
|
||||
friendly_name: "Patio temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
import type {
|
||||
MatterCommissioningParameters,
|
||||
MatterFabricData,
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterNodeDiagnostics,
|
||||
} from "../../../../../src/data/matter";
|
||||
import { NetworkType, NodeType } from "../../../../../src/data/matter";
|
||||
import type {
|
||||
MatterLockInfo,
|
||||
MatterLockUser,
|
||||
MatterLockUsersResponse,
|
||||
SetMatterLockCredentialResult,
|
||||
} from "../../../../../src/data/matter-lock";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const EXT_PAN_ID = "dead00beef00cafe";
|
||||
const THREAD_NETWORK = "ha-thread";
|
||||
|
||||
const NODES: MatterNetworkTopologyNode[] = [
|
||||
{
|
||||
id: "otbr",
|
||||
kind: "border_router",
|
||||
network_type: "thread",
|
||||
ha_device_id: null,
|
||||
role: "leader",
|
||||
available: true,
|
||||
ext_address: "f6a1c30d2b4e5f61",
|
||||
rloc16: 0x4000,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
host_name: "homeassistant",
|
||||
vendor_name: "Home Assistant",
|
||||
model_name: "OpenThread Border Router",
|
||||
},
|
||||
{
|
||||
id: "wifi-ap",
|
||||
kind: "wifi_ap",
|
||||
network_type: "wifi",
|
||||
ha_device_id: null,
|
||||
available: true,
|
||||
ssid: "Home",
|
||||
bssid: "3c:37:86:11:22:33",
|
||||
vendor_name: "Ubiquiti",
|
||||
model_name: "U6 Pro",
|
||||
},
|
||||
{
|
||||
id: "node-1",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 1,
|
||||
ha_device_id: "matter-kitchen-light",
|
||||
available: true,
|
||||
role: "router",
|
||||
ext_address: "10a2b3c4d5e6f708",
|
||||
rloc16: 0x8401,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Nanoleaf",
|
||||
model_name: "Essentials A19",
|
||||
},
|
||||
{
|
||||
id: "node-2",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 2,
|
||||
ha_device_id: "matter-side-door-lock",
|
||||
available: true,
|
||||
role: "sleepy_end_device",
|
||||
ext_address: "20b3c4d5e6f70819",
|
||||
rloc16: 0x8402,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Aqara",
|
||||
model_name: "Smart Lock U100",
|
||||
},
|
||||
{
|
||||
id: "node-3",
|
||||
kind: "matter",
|
||||
network_type: "wifi",
|
||||
node_id: 3,
|
||||
ha_device_id: "matter-office-plug",
|
||||
available: true,
|
||||
ssid: "Home",
|
||||
vendor_name: "Eve",
|
||||
model_name: "Energy",
|
||||
},
|
||||
{
|
||||
id: "node-4",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 4,
|
||||
ha_device_id: "matter-patio-sensor",
|
||||
available: false,
|
||||
role: "end_device",
|
||||
ext_address: "30c4d5e6f708192a",
|
||||
rloc16: 0x8403,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Eve",
|
||||
model_name: "Weather",
|
||||
},
|
||||
{
|
||||
id: "thread-unknown-1",
|
||||
kind: "thread_unknown",
|
||||
network_type: "thread",
|
||||
available: true,
|
||||
role: "end_device",
|
||||
ext_address: "40d5e6f708192a3b",
|
||||
rloc16: 0x8404,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
},
|
||||
];
|
||||
|
||||
const connection = (
|
||||
source: string,
|
||||
target: string,
|
||||
network: string,
|
||||
strength: MatterNetworkTopologyConnection["strength"],
|
||||
lqi?: number,
|
||||
rssi?: number
|
||||
): MatterNetworkTopologyConnection => ({
|
||||
source,
|
||||
target,
|
||||
network,
|
||||
strength,
|
||||
source_to_target: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
|
||||
target_to_source: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
|
||||
via_route_table: false,
|
||||
path_cost: null,
|
||||
});
|
||||
|
||||
const CONNECTIONS: MatterNetworkTopologyConnection[] = [
|
||||
connection("otbr", "node-1", "thread", "strong", 245, -42),
|
||||
connection("otbr", "node-2", "thread", "medium", 160, -68),
|
||||
connection("node-1", "node-4", "thread", "weak", 84, -86),
|
||||
connection("node-1", "thread-unknown-1", "thread", "medium", 172, -63),
|
||||
connection("wifi-ap", "node-3", "wifi", "strong", undefined, -47),
|
||||
];
|
||||
|
||||
const TOPOLOGY: MatterNetworkTopology = {
|
||||
collected_at: Date.now() / 1000,
|
||||
nodes: NODES,
|
||||
connections: CONNECTIONS,
|
||||
};
|
||||
|
||||
const buildTopology = (): MatterNetworkTopology => ({
|
||||
...TOPOLOGY,
|
||||
collected_at: Date.now() / 1000,
|
||||
});
|
||||
|
||||
const FABRICS: MatterFabricData[] = [
|
||||
{
|
||||
fabric_id: 1,
|
||||
vendor_id: 4939,
|
||||
fabric_index: 1,
|
||||
fabric_label: "Home Assistant",
|
||||
vendor_name: "Home Assistant",
|
||||
},
|
||||
];
|
||||
|
||||
const NODE_TYPE_BY_ROLE: Record<string, NodeType> = {
|
||||
router: NodeType.ROUTING_END_DEVICE,
|
||||
sleepy_end_device: NodeType.SLEEPY_END_DEVICE,
|
||||
end_device: NodeType.END_DEVICE,
|
||||
};
|
||||
|
||||
const NODES_BY_DEVICE_ID = new Map(
|
||||
NODES.filter((node) => node.ha_device_id).map((node) => [
|
||||
node.ha_device_id!,
|
||||
node,
|
||||
])
|
||||
);
|
||||
|
||||
const nodeIpAddress = (node: MatterNetworkTopologyNode): string =>
|
||||
node.network_type === "thread"
|
||||
? `fd11:2233:4455:6677::${(node.node_id ?? 0).toString(16)}`
|
||||
: `192.168.1.${100 + (node.node_id ?? 0)}`;
|
||||
|
||||
// Diagnostics are derived from the topology so a device's transport,
|
||||
// availability and node type match the map. The device page reads them per
|
||||
// device, and gates its actions on `available` and `network_type`.
|
||||
const buildNodeDiagnostics = (
|
||||
node: MatterNetworkTopologyNode
|
||||
): MatterNodeDiagnostics => ({
|
||||
node_id: node.node_id!,
|
||||
network_type:
|
||||
node.network_type === "thread" ? NetworkType.THREAD : NetworkType.WIFI,
|
||||
node_type: node.is_bridge
|
||||
? NodeType.BRIDGE
|
||||
: (NODE_TYPE_BY_ROLE[node.role ?? ""] ?? NodeType.END_DEVICE),
|
||||
network_name: node.network_name ?? node.ssid ?? undefined,
|
||||
ip_adresses: [nodeIpAddress(node)],
|
||||
mac_address: node.ext_address?.match(/.{2}/g)?.join(":"),
|
||||
available: node.available !== false,
|
||||
active_fabrics: FABRICS,
|
||||
active_fabric_index: 1,
|
||||
});
|
||||
|
||||
// The backend resolves the device before acting, so both node commands answer
|
||||
// the same way when it cannot.
|
||||
const nodeNotFound = (deviceId: string) =>
|
||||
Promise.reject({
|
||||
code: "node_not_found",
|
||||
message: `No Matter node for device ${deviceId}`,
|
||||
});
|
||||
|
||||
// The manual and QR codes below are the Matter test payload for passcode
|
||||
// 20202021 with discriminator 3840; the three have to agree.
|
||||
const COMMISSIONING_PARAMETERS: MatterCommissioningParameters = {
|
||||
setup_pin_code: 20202021,
|
||||
setup_manual_code: "34970112332",
|
||||
setup_qr_code: "MT:Y.K9042C00KA0648G00",
|
||||
};
|
||||
|
||||
const LOCK_INFO: MatterLockInfo = {
|
||||
supports_user_management: true,
|
||||
supported_credential_types: ["pin"],
|
||||
max_users: 10,
|
||||
max_pin_users: 10,
|
||||
max_rfid_users: null,
|
||||
max_credentials_per_user: 2,
|
||||
min_pin_length: 4,
|
||||
max_pin_length: 8,
|
||||
min_rfid_length: null,
|
||||
max_rfid_length: null,
|
||||
};
|
||||
|
||||
const initialLockUsers = (): MatterLockUser[] => [
|
||||
{
|
||||
user_index: 1,
|
||||
user_name: "Anne",
|
||||
user_unique_id: 1,
|
||||
user_status: "occupied_enabled",
|
||||
user_type: "unrestricted_user",
|
||||
credential_rule: "single",
|
||||
credentials: [{ type: "pin", index: 1 }],
|
||||
next_user_index: 2,
|
||||
},
|
||||
{
|
||||
user_index: 2,
|
||||
user_name: "Cleaner",
|
||||
user_unique_id: 2,
|
||||
user_status: "occupied_disabled",
|
||||
user_type: "week_day_schedule_user",
|
||||
credential_rule: "single",
|
||||
credentials: [{ type: "pin", index: 2 }],
|
||||
next_user_index: null,
|
||||
},
|
||||
];
|
||||
|
||||
// The manage dialog reloads the list after every add, edit and delete, so the
|
||||
// mocked services keep the lock's users rather than answering from a constant,
|
||||
// which would make every change look like it was reverted.
|
||||
const lockUsers = new Map<string, MatterLockUser[]>();
|
||||
|
||||
const usersFor = (entityId: string): MatterLockUser[] => {
|
||||
let users = lockUsers.get(entityId);
|
||||
if (!users) {
|
||||
users = initialLockUsers();
|
||||
lockUsers.set(entityId, users);
|
||||
}
|
||||
return users;
|
||||
};
|
||||
|
||||
// Lowest free slot, the way a lock hands out user and credential indexes.
|
||||
const nextFreeIndex = (taken: number[]): number => {
|
||||
let index = 1;
|
||||
while (taken.includes(index)) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
export const mockMatter = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("matter/network_topology", () => buildTopology());
|
||||
|
||||
hass.mockWS("matter/subscribe_network_topology", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.(buildTopology()))
|
||||
);
|
||||
|
||||
hass.mockWS("matter/node_diagnostics", (msg: { device_id: string }) => {
|
||||
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
|
||||
return node ? buildNodeDiagnostics(node) : nodeNotFound(msg.device_id);
|
||||
});
|
||||
|
||||
hass.mockWS("matter/ping_node", (msg: { device_id: string }) => {
|
||||
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
|
||||
return node
|
||||
? { [nodeIpAddress(node)]: node.available !== false }
|
||||
: nodeNotFound(msg.device_id);
|
||||
});
|
||||
|
||||
hass.mockWS("matter/interview_node", () => undefined);
|
||||
|
||||
// Actions the device page offers for an available node. Without these the
|
||||
// dialogs behind them fail with `command_not_mocked`.
|
||||
hass.mockWS(
|
||||
"matter/open_commissioning_window",
|
||||
() => COMMISSIONING_PARAMETERS
|
||||
);
|
||||
hass.mockWS("matter/remove_matter_fabric", () => undefined);
|
||||
hass.mockWS("matter/set_wifi_credentials", () => undefined);
|
||||
hass.mockWS("matter/set_thread", () => undefined);
|
||||
|
||||
// The lock device exposes "Manage lock", whose dialog reads back the response
|
||||
// of these services.
|
||||
hass.mockService("matter", "get_lock_info", (_data, target) => ({
|
||||
[target!.entity_id]: LOCK_INFO,
|
||||
}));
|
||||
hass.mockService("matter", "get_lock_users", (_data, target) => ({
|
||||
// Copied, the way a real response would be: the dialog assigns the list to
|
||||
// reactive state, so handing back the same array leaves it unchanged and
|
||||
// the list never rerenders.
|
||||
[target!.entity_id]: {
|
||||
max_users: LOCK_INFO.max_users!,
|
||||
users: usersFor(target!.entity_id).map((user) => ({
|
||||
...user,
|
||||
credentials: user.credentials.map((credential) => ({ ...credential })),
|
||||
})),
|
||||
} satisfies MatterLockUsersResponse,
|
||||
}));
|
||||
|
||||
// Renames the user the credential below created, or edits an existing one.
|
||||
hass.mockService("matter", "set_lock_user", (data, target) => {
|
||||
const user = usersFor(target!.entity_id).find(
|
||||
(candidate) => candidate.user_index === data?.user_index
|
||||
);
|
||||
if (user) {
|
||||
if (data?.user_name !== undefined) {
|
||||
user.user_name = data.user_name;
|
||||
}
|
||||
if (data?.user_type !== undefined) {
|
||||
user.user_type = data.user_type;
|
||||
}
|
||||
if (data?.credential_rule !== undefined) {
|
||||
user.credential_rule = data.credential_rule;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
hass.mockService("matter", "clear_lock_user", (data, target) => {
|
||||
const users = usersFor(target!.entity_id);
|
||||
const index = users.findIndex(
|
||||
(candidate) => candidate.user_index === data?.user_index
|
||||
);
|
||||
if (index !== -1) {
|
||||
users.splice(index, 1);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// Adding a user starts here: the credential creates it, and the dialog reads
|
||||
// the assigned index straight back to name it.
|
||||
hass.mockService("matter", "set_lock_credential", (data, target) => {
|
||||
const users = usersFor(target!.entity_id);
|
||||
const userIndex =
|
||||
(data?.user_index as number | null | undefined) ??
|
||||
nextFreeIndex(
|
||||
users
|
||||
.map((user) => user.user_index)
|
||||
.filter((i): i is number => i !== null)
|
||||
);
|
||||
const credentialIndex =
|
||||
(data?.credential_index as number | null | undefined) ??
|
||||
nextFreeIndex(
|
||||
users.flatMap((user) =>
|
||||
user.credentials
|
||||
.map((credential) => credential.index)
|
||||
.filter((i): i is number => i !== null)
|
||||
)
|
||||
);
|
||||
const credential = {
|
||||
type: (data?.credential_type as string) ?? "pin",
|
||||
index: credentialIndex,
|
||||
};
|
||||
const user = users.find((candidate) => candidate.user_index === userIndex);
|
||||
if (user) {
|
||||
user.credentials = [...user.credentials, credential];
|
||||
} else {
|
||||
users.push({
|
||||
user_index: userIndex,
|
||||
user_name: null,
|
||||
user_unique_id: userIndex,
|
||||
user_status: data?.user_status ?? "occupied_enabled",
|
||||
user_type: data?.user_type ?? "unrestricted_user",
|
||||
credential_rule: "single",
|
||||
credentials: [credential],
|
||||
next_user_index: null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
[target!.entity_id]: {
|
||||
credential_index: credentialIndex,
|
||||
user_index: userIndex,
|
||||
next_credential_index: null,
|
||||
} satisfies SetMatterLockCredentialResult,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-mqtt";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"mqtt-fridge-sensor",
|
||||
"Fridge sensor",
|
||||
"Xiaomi",
|
||||
"LYWSD03MMC",
|
||||
ENTRY_ID,
|
||||
{ area_id: "kitchen" }
|
||||
),
|
||||
device(
|
||||
"mqtt-garage-door",
|
||||
"Garage door",
|
||||
"Shelly",
|
||||
"Shelly Plus 1",
|
||||
ENTRY_ID
|
||||
),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"sensor.fridge_temperature",
|
||||
"mqtt-fridge-sensor",
|
||||
ENTRY_ID,
|
||||
"mqtt"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.fridge_battery",
|
||||
"mqtt-fridge-sensor",
|
||||
ENTRY_ID,
|
||||
"mqtt"
|
||||
),
|
||||
registryEntry("cover.garage_door", "mqtt-garage-door", ENTRY_ID, "mqtt"),
|
||||
];
|
||||
|
||||
export const mqttFixtures: ConnectivityFixtures = {
|
||||
components: ["mqtt"],
|
||||
// `execute_script` too: the panel publishes through a script action.
|
||||
commands: ["mqtt/", "execute_script"],
|
||||
manifests: [manifest("mqtt", "MQTT", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "mqtt", "core-mosquitto", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"sensor.fridge_temperature": {
|
||||
entity_id: "sensor.fridge_temperature",
|
||||
state: "21.4",
|
||||
attributes: {
|
||||
friendly_name: "Fridge temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"sensor.fridge_battery": {
|
||||
entity_id: "sensor.fridge_battery",
|
||||
state: "92",
|
||||
attributes: {
|
||||
friendly_name: "Fridge battery",
|
||||
device_class: "battery",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"cover.garage_door": {
|
||||
entity_id: "cover.garage_door",
|
||||
state: "closed",
|
||||
attributes: { friendly_name: "Garage door", device_class: "garage" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
MQTTDeviceDebugInfo,
|
||||
MQTTMessage,
|
||||
} from "../../../../../src/data/mqtt";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const PAYLOADS: Record<string, () => string> = {
|
||||
"homeassistant/status": () => "online",
|
||||
"zigbee2mqtt/bridge/state": () => '{"state":"online"}',
|
||||
default: () =>
|
||||
JSON.stringify({
|
||||
battery: 92,
|
||||
linkquality: 120,
|
||||
// Built from whole tenths, so the payload never carries the noise a
|
||||
// float sum leaves behind, like 21.599999999999998.
|
||||
temperature: (214 + Math.floor(Math.random() * 11)) / 10,
|
||||
}),
|
||||
};
|
||||
|
||||
// Subscriptions take a topic filter, but a message carries the topic it was
|
||||
// actually published on, so a filter has to be resolved to one concrete topic
|
||||
// before it can be echoed back.
|
||||
const resolveFilter = (filter: string): string =>
|
||||
filter
|
||||
.split("/")
|
||||
.flatMap((level) => {
|
||||
if (level === "+") {
|
||||
return ["kitchen"];
|
||||
}
|
||||
if (level === "#") {
|
||||
return ["kitchen", "temperature"];
|
||||
}
|
||||
return [level];
|
||||
})
|
||||
.join("/") || "homeassistant/status";
|
||||
|
||||
const buildMessage = (topic: string, qos: number): MQTTMessage => ({
|
||||
topic,
|
||||
payload: (PAYLOADS[topic] ?? PAYLOADS.default)(),
|
||||
qos,
|
||||
retain: 0,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// A filter matches a topic level by level: "+" stands for one level, "#" for
|
||||
// the rest of them.
|
||||
const filterMatches = (filter: string, topic: string): boolean => {
|
||||
const filterLevels = filter.split("/");
|
||||
const topicLevels = topic.split("/");
|
||||
for (let index = 0; index < filterLevels.length; index += 1) {
|
||||
if (filterLevels[index] === "#") {
|
||||
return true;
|
||||
}
|
||||
if (index >= topicLevels.length) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filterLevels[index] !== "+" &&
|
||||
filterLevels[index] !== topicLevels[index]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return filterLevels.length === topicLevels.length;
|
||||
};
|
||||
|
||||
// The panel's listen card and its publish button talk to each other through
|
||||
// the broker, so the mock keeps the subscriptions and delivers to them.
|
||||
const subscriptions = new Set<{
|
||||
filter: string;
|
||||
qos: number;
|
||||
deliver: (message: MQTTMessage) => void;
|
||||
}>();
|
||||
|
||||
const topicDebug = (topic: string) => ({
|
||||
topic,
|
||||
messages: [buildMessage(topic, 0)],
|
||||
});
|
||||
|
||||
// Keyed by device, the way the backend builds this per requested device.
|
||||
const DEBUG_INFO: Record<string, MQTTDeviceDebugInfo> = {
|
||||
"mqtt-fridge-sensor": {
|
||||
entities: [
|
||||
{
|
||||
entity_id: "sensor.fridge_temperature",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/sensor/fridge/temperature/config",
|
||||
payload: {
|
||||
name: "Temperature",
|
||||
state_topic: "zigbee2mqtt/fridge",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
|
||||
transmitted: [],
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.fridge_battery",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/sensor/fridge/battery/config",
|
||||
payload: {
|
||||
name: "Battery",
|
||||
state_topic: "zigbee2mqtt/fridge",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
|
||||
transmitted: [],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
"mqtt-garage-door": {
|
||||
entities: [
|
||||
{
|
||||
entity_id: "cover.garage_door",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/cover/garage/config",
|
||||
payload: {
|
||||
name: "Garage door",
|
||||
state_topic: "shellyplus1/status/cover:0",
|
||||
command_topic: "shellyplus1/command/cover:0",
|
||||
device_class: "garage",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("shellyplus1/status/cover:0")],
|
||||
transmitted: [topicDebug("shellyplus1/command/cover:0")],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const mockMqtt = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"mqtt/subscribe",
|
||||
(msg: { topic: string; qos?: number }, _hass, onChange) => {
|
||||
// Echo a message on the subscribed topic every few seconds so the
|
||||
// listen card in the MQTT panel shows traffic.
|
||||
const qos = msg.qos ?? 0;
|
||||
const topic = resolveFilter(msg.topic);
|
||||
const deliver = (message: MQTTMessage) => onChange?.(message);
|
||||
const subscription = { filter: msg.topic, qos, deliver };
|
||||
subscriptions.add(subscription);
|
||||
const send = () => deliver(buildMessage(topic, qos));
|
||||
const stopInitial = emitInitial(send);
|
||||
const interval = window.setInterval(send, 3000);
|
||||
return () => {
|
||||
stopInitial();
|
||||
clearInterval(interval);
|
||||
subscriptions.delete(subscription);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// The panel publishes through a script action rather than a `mqtt/` command,
|
||||
// so without this the publish button only ever reports a failure. Delivering
|
||||
// to the matching subscriptions is what makes the two halves of the panel
|
||||
// work together.
|
||||
hass.mockWS(
|
||||
"execute_script",
|
||||
(msg: { sequence: { action?: string; data?: Record<string, any> }[] }) => {
|
||||
msg.sequence
|
||||
?.filter((action) => action.action === "mqtt.publish")
|
||||
.forEach((action) => {
|
||||
const topic = String(action.data?.topic ?? "");
|
||||
const message: MQTTMessage = {
|
||||
topic,
|
||||
payload: String(action.data?.payload ?? ""),
|
||||
qos: Number(action.data?.qos ?? 0),
|
||||
retain: action.data?.retain ? 1 : 0,
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
subscriptions.forEach((subscription) => {
|
||||
if (filterMatches(subscription.filter, topic)) {
|
||||
subscription.deliver(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { context: { id: "mock-context" }, response: {} };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"mqtt/device/debug_info",
|
||||
(msg: { device_id: string }): MQTTDeviceDebugInfo =>
|
||||
DEBUG_INFO[msg.device_id] ?? { entities: [], triggers: [] }
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
minutesAgo,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-rf-bridge";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"rf-bridge-garage",
|
||||
"Garage bridge",
|
||||
"Sonoff",
|
||||
"RF Bridge R2",
|
||||
ENTRY_ID,
|
||||
{ area_id: "garden" }
|
||||
),
|
||||
device("rf-bridge-shed", "Shed bridge", "Sonoff", "RF Bridge R2", ENTRY_ID),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"radio_frequency.garage_bridge",
|
||||
"rf-bridge-garage",
|
||||
ENTRY_ID,
|
||||
"esphome",
|
||||
"Transceiver"
|
||||
),
|
||||
registryEntry(
|
||||
"radio_frequency.shed_bridge",
|
||||
"rf-bridge-shed",
|
||||
ENTRY_ID,
|
||||
"esphome",
|
||||
"Transceiver"
|
||||
),
|
||||
];
|
||||
|
||||
export const radioFrequencyFixtures: ConnectivityFixtures = {
|
||||
components: ["radio_frequency"],
|
||||
commands: ["radio_frequency/"],
|
||||
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
|
||||
configEntries: [
|
||||
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"radio_frequency.garage_bridge": {
|
||||
entity_id: "radio_frequency.garage_bridge",
|
||||
state: minutesAgo(47),
|
||||
attributes: { friendly_name: "Garage bridge Transceiver" },
|
||||
},
|
||||
"radio_frequency.shed_bridge": {
|
||||
entity_id: "radio_frequency.shed_bridge",
|
||||
state: "unknown",
|
||||
attributes: { friendly_name: "Shed bridge Transceiver" },
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
entity_component: {
|
||||
"component.radio_frequency.entity_component._.name": "Transceiver",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RadioFrequencyTransmitter } from "../../../../../src/data/radio_frequency";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
const TRANSMITTERS: RadioFrequencyTransmitter[] = [
|
||||
{
|
||||
entity_id: "radio_frequency.garage_bridge",
|
||||
device_id: "rf-bridge-garage",
|
||||
config_entry_id: "mock-rf-bridge",
|
||||
supported_frequency_ranges: [[433920000, 433920000]],
|
||||
supported_modulations: ["OOK"],
|
||||
},
|
||||
{
|
||||
entity_id: "radio_frequency.shed_bridge",
|
||||
device_id: "rf-bridge-shed",
|
||||
config_entry_id: "mock-rf-bridge",
|
||||
supported_frequency_ranges: [[433920000, 433920000]],
|
||||
supported_modulations: ["OOK"],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockRadioFrequency = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("radio_frequency/list", () => ({
|
||||
transmitters: TRANSMITTERS,
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const serialFixtures: ConnectivityFixtures = {
|
||||
components: ["usb"],
|
||||
commands: ["usb/"],
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { SerialPortUsage } from "../../../../../src/data/usb";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
const PORTS: SerialPortUsage[] = [
|
||||
{
|
||||
device: "/dev/ttyUSB0",
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-Nabu_Casa_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
|
||||
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
|
||||
manufacturer: "Nabu Casa",
|
||||
description: "Home Assistant Connect ZBT-1",
|
||||
interface_description: "Connect ZBT-1",
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: 256,
|
||||
matching_integrations: ["zha"],
|
||||
present: true,
|
||||
consumers: [
|
||||
{
|
||||
kind: "config_entry",
|
||||
title: "Home Assistant Connect ZBT-1",
|
||||
active: true,
|
||||
domain: "zha",
|
||||
config_entry_id: "mock-zha",
|
||||
slug: null,
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
device: "/dev/ttyACM0",
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-Zooz_800_Z-Wave_Stick_533D004242-if00",
|
||||
serial_number: "533D004242",
|
||||
manufacturer: "Zooz",
|
||||
description: "800 Series Z-Wave Long Range",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: 256,
|
||||
matching_integrations: ["zwave_js"],
|
||||
present: true,
|
||||
consumers: [
|
||||
{
|
||||
kind: "config_entry",
|
||||
title: "Z-Wave",
|
||||
active: true,
|
||||
domain: "zwave_js",
|
||||
config_entry_id: "mock-zwave-js",
|
||||
slug: null,
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
device: "/dev/ttyUSB1",
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
|
||||
serial_number: "A50285BI",
|
||||
manufacturer: "FTDI",
|
||||
description: "FT232R USB UART",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "0403",
|
||||
pid: "6001",
|
||||
bcd_device: 1536,
|
||||
matching_integrations: [],
|
||||
present: true,
|
||||
consumers: [],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
// A port that is configured but not currently plugged in. Its add-on icon
|
||||
// is requested straight from /api/hassio/addons/<slug>/icon, which the
|
||||
// demo has no backend for, so the icon stays blank here.
|
||||
device: "/dev/ttyUSB2",
|
||||
resolved_device: null,
|
||||
serial_number: "0001",
|
||||
manufacturer: "Silicon Labs",
|
||||
description: "CP2102 USB to UART Bridge Controller",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: null,
|
||||
matching_integrations: [],
|
||||
present: false,
|
||||
consumers: [
|
||||
{
|
||||
kind: "app",
|
||||
title: "ESPHome Device Builder",
|
||||
active: false,
|
||||
domain: null,
|
||||
config_entry_id: null,
|
||||
slug: "esphome",
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSerial = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("usb/list_serial_ports", () => PORTS);
|
||||
hass.mockWS("usb/scan", () => undefined);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// Mocked WebSocket subscriptions are registered synchronously, so a callback
|
||||
// invoked straight away can land before the subscriber is ready for it: pages
|
||||
// that ignore messages received before their first render drop it, and
|
||||
// `createCollection` overwrites it with the empty initial fetch. Emitting the
|
||||
// first message from a timeout matches the real backend, which always answers
|
||||
// asynchronously.
|
||||
export const emitInitial = (send: () => void): (() => void) => {
|
||||
const timeout = window.setTimeout(send, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const tagsFixtures: ConnectivityFixtures = {
|
||||
components: ["tag"],
|
||||
commands: ["tag/"],
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import { configEntry } from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const THREAD_ENTRY_ID = "mock-thread";
|
||||
const OTBR_ENTRY_ID = "mock-otbr";
|
||||
|
||||
export const threadFixtures: ConnectivityFixtures = {
|
||||
components: ["thread", "otbr"],
|
||||
commands: ["thread/", "otbr/"],
|
||||
manifests: [
|
||||
manifest("thread", "Thread", {
|
||||
integration_type: "service",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("otbr", "Open Thread Border Router", {
|
||||
integration_type: "service",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "service",
|
||||
entry: configEntry(THREAD_ENTRY_ID, "thread", "Thread"),
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: configEntry(OTBR_ENTRY_ID, "otbr", "Open Thread Border Router"),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,441 @@
|
||||
import type { OTBRInfoDict } from "../../../../../src/data/otbr";
|
||||
import type {
|
||||
ThreadDataSet,
|
||||
ThreadRouter,
|
||||
} from "../../../../../src/data/thread";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const HA_EXT_PAN_ID = "DEAD00BEEF00CAFE";
|
||||
const AMAZON_EXT_PAN_ID = "0011223344556677";
|
||||
|
||||
const OTBR_EXT_ADDRESS = "f6a1c30d2b4e5f61";
|
||||
const OTBR_BORDER_AGENT_ID = "230c6a1ac57f6f4be262acf32e5ef52c";
|
||||
|
||||
const ROUTERS: ThreadRouter[] = [
|
||||
{
|
||||
instance_name: "HomeAssistant OpenThreadBorderRouter",
|
||||
addresses: ["192.168.1.10"],
|
||||
border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
brand: "homeassistant",
|
||||
extended_address: OTBR_EXT_ADDRESS,
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "OpenThread Border Router",
|
||||
network_name: "ha-thread",
|
||||
server: "core-openthread-border-router.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Home Assistant",
|
||||
},
|
||||
{
|
||||
instance_name: "HomePod mini",
|
||||
addresses: ["192.168.1.24"],
|
||||
border_agent_id: "6a1ac57f6f4be262acf32e5ef52c230c",
|
||||
brand: "apple",
|
||||
extended_address: "aabbccddeeff0011",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "HomePod mini",
|
||||
network_name: "ha-thread",
|
||||
server: "homepod-mini.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Apple Inc.",
|
||||
},
|
||||
{
|
||||
instance_name: "Nest Hub",
|
||||
addresses: ["192.168.1.31"],
|
||||
border_agent_id: "ac57f6f4be262acf32e5ef52c230c6a1",
|
||||
brand: "google",
|
||||
extended_address: "bbccddeeff001122",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "Google Nest Hub",
|
||||
network_name: "ha-thread",
|
||||
server: "nest-hub.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Google Inc.",
|
||||
},
|
||||
{
|
||||
instance_name: "Echo (4th Gen)",
|
||||
addresses: ["192.168.1.42"],
|
||||
border_agent_id: "57f6f4be262acf32e5ef52c230c6a1ac",
|
||||
brand: "amazon",
|
||||
extended_address: "ccddeeff00112233",
|
||||
extended_pan_id: AMAZON_EXT_PAN_ID,
|
||||
model_name: "Echo",
|
||||
network_name: "AmazonThread",
|
||||
server: "amazon-echo.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Amazon",
|
||||
},
|
||||
];
|
||||
|
||||
const decodeUtf8 = (value: string): string | undefined => {
|
||||
const bytes = Uint8Array.from(
|
||||
(value.match(/../g) ?? []).map((byte) => parseInt(byte, 16))
|
||||
);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const tlv = (type: number, value: string) =>
|
||||
type.toString(16).padStart(2, "0").toUpperCase() +
|
||||
(value.length / 2).toString(16).padStart(2, "0").toUpperCase() +
|
||||
value.toUpperCase();
|
||||
|
||||
const textToHex = (text: string) =>
|
||||
Array.from(text)
|
||||
.map((character) => character.charCodeAt(0).toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const replaceTlvField = (value: string, type: number, replacement: string) => {
|
||||
let index = 0;
|
||||
let out = "";
|
||||
let replaced = false;
|
||||
while (index + 4 <= value.length) {
|
||||
const fieldType = parseInt(value.slice(index, index + 2), 16);
|
||||
const length = parseInt(value.slice(index + 2, index + 4), 16);
|
||||
const end = index + 4 + length * 2;
|
||||
out +=
|
||||
fieldType === type ? tlv(type, replacement) : value.slice(index, end);
|
||||
replaced = replaced || fieldType === type;
|
||||
index = end;
|
||||
}
|
||||
return replaced ? out : out + tlv(type, replacement);
|
||||
};
|
||||
|
||||
const buildDatasetTlv = (dataset: ThreadDataSet) =>
|
||||
[
|
||||
tlv(0x0e, "0000000000010000"),
|
||||
tlv(0x00, `00${(dataset.channel ?? 15).toString(16).padStart(4, "0")}`),
|
||||
tlv(0x35, "000040010200"),
|
||||
tlv(0x02, dataset.extended_pan_id),
|
||||
tlv(0x07, "FD11220000000000"),
|
||||
tlv(0x05, "00112233445566778899AABBCCDDEEFF"),
|
||||
dataset.network_name === null
|
||||
? ""
|
||||
: tlv(0x03, textToHex(dataset.network_name)),
|
||||
tlv(0x01, (dataset.pan_id ?? "1234").padStart(4, "0")),
|
||||
tlv(0x04, "1035060004001FFFE00C0402A0F7F800"),
|
||||
tlv(0x0c, "02A0F7F8"),
|
||||
].join("");
|
||||
|
||||
const parseDatasetTlv = (value: string) => {
|
||||
if (value.length % 2 !== 0 || !/^[0-9A-Fa-f]*$/.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const fields = new Map<number, string>();
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
if (index + 4 > value.length) {
|
||||
return undefined;
|
||||
}
|
||||
const type = parseInt(value.slice(index, index + 2), 16);
|
||||
const length = parseInt(value.slice(index + 2, index + 4), 16);
|
||||
const start = index + 4;
|
||||
const end = start + length * 2;
|
||||
if (end > value.length) {
|
||||
return undefined;
|
||||
}
|
||||
if (fields.has(type)) {
|
||||
return undefined;
|
||||
}
|
||||
fields.set(type, value.slice(start, end).toUpperCase());
|
||||
index = end;
|
||||
}
|
||||
const extendedPanId = fields.get(0x02);
|
||||
const networkName = fields.get(0x03);
|
||||
const activeTimestamp = fields.get(0x0e);
|
||||
if (
|
||||
!extendedPanId ||
|
||||
extendedPanId.length !== 16 ||
|
||||
!activeTimestamp ||
|
||||
activeTimestamp.length !== 16
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const decodedName =
|
||||
networkName === undefined ? null : decodeUtf8(networkName);
|
||||
if (decodedName === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = fields.get(0x00);
|
||||
const channelNumber = channel ? parseInt(channel.slice(2), 16) : null;
|
||||
if (channelNumber === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
activeTimestamp,
|
||||
extendedPanId,
|
||||
networkName: decodedName,
|
||||
panId: fields.get(0x01) ?? null,
|
||||
channel: channelNumber,
|
||||
};
|
||||
};
|
||||
|
||||
const DATASETS: ThreadDataSet[] = [
|
||||
{
|
||||
channel: 15,
|
||||
created: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
dataset_id: "ha-thread-dataset",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
network_name: "ha-thread",
|
||||
pan_id: "1234",
|
||||
preferred_border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
preferred_extended_address: OTBR_EXT_ADDRESS,
|
||||
preferred: true,
|
||||
source: "otbr",
|
||||
},
|
||||
];
|
||||
|
||||
const DATASET_TLVS: Record<string, string> = Object.fromEntries(
|
||||
DATASETS.map((dataset) => [dataset.dataset_id, buildDatasetTlv(dataset)])
|
||||
);
|
||||
|
||||
const OTBR_INFO: OTBRInfoDict = {
|
||||
[OTBR_EXT_ADDRESS]: {
|
||||
active_dataset_tlvs: DATASET_TLVS["ha-thread-dataset"],
|
||||
border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
channel: 15,
|
||||
extended_address: OTBR_EXT_ADDRESS,
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
url: "http://core-openthread-border-router:8081",
|
||||
},
|
||||
};
|
||||
|
||||
let added = 0;
|
||||
let created = 0;
|
||||
|
||||
const randomExtendedPanId = () =>
|
||||
Array.from({ length: 8 }, () =>
|
||||
Math.floor(Math.random() * 256)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const moveRouter = (
|
||||
extendedAddress: string,
|
||||
extendedPanId: string,
|
||||
networkName: string | null,
|
||||
datasetId: string
|
||||
) => {
|
||||
const info = OTBR_INFO[extendedAddress];
|
||||
const moved = DATASETS.find((item) => item.dataset_id === datasetId);
|
||||
if (info) {
|
||||
info.extended_pan_id = extendedPanId;
|
||||
info.active_dataset_tlvs =
|
||||
DATASET_TLVS[datasetId] ?? info.active_dataset_tlvs;
|
||||
info.channel = moved?.channel ?? info.channel;
|
||||
}
|
||||
const router = ROUTERS.find(
|
||||
(candidate) => candidate.extended_address === extendedAddress
|
||||
);
|
||||
if (router) {
|
||||
router.extended_pan_id = extendedPanId;
|
||||
router.network_name = networkName;
|
||||
announce(router);
|
||||
}
|
||||
};
|
||||
|
||||
type RouterListener = (event: {
|
||||
key: string;
|
||||
type: "router_discovered" | "router_removed";
|
||||
data: ThreadRouter;
|
||||
}) => void;
|
||||
|
||||
const listeners = new Set<RouterListener>();
|
||||
|
||||
const announce = (router: ThreadRouter) =>
|
||||
listeners.forEach((listener) =>
|
||||
listener({
|
||||
key: router.extended_address,
|
||||
type: "router_discovered",
|
||||
data: router,
|
||||
})
|
||||
);
|
||||
|
||||
export const mockThread = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("thread/discover_routers", (_msg, _hass, onChange) => {
|
||||
const listener = onChange as RouterListener | undefined;
|
||||
if (listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
const stopInitial = emitInitial(() => ROUTERS.forEach(announce));
|
||||
return () => {
|
||||
stopInitial();
|
||||
if (listener) {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
hass.mockWS("thread/list_datasets", () => ({
|
||||
datasets: DATASETS.map((dataset) => ({ ...dataset })),
|
||||
}));
|
||||
hass.mockWS("thread/get_dataset_tlv", (msg: { dataset_id: string }) => {
|
||||
const value = DATASET_TLVS[msg.dataset_id];
|
||||
if (!value) {
|
||||
throw new Error(`Dataset ${msg.dataset_id} not found`);
|
||||
}
|
||||
return { tlv: value };
|
||||
});
|
||||
|
||||
hass.mockWS("otbr/info", () => OTBR_INFO);
|
||||
|
||||
hass.mockWS(
|
||||
"thread/add_dataset_tlv",
|
||||
(msg: { source: string; tlv: string }) => {
|
||||
const parsed = parseDatasetTlv(msg.tlv);
|
||||
if (!parsed) {
|
||||
throw new Error("Invalid dataset");
|
||||
}
|
||||
const existing = DATASETS.find(
|
||||
(candidate) => candidate.extended_pan_id === parsed.extendedPanId
|
||||
);
|
||||
if (existing) {
|
||||
const current = parseDatasetTlv(DATASET_TLVS[existing.dataset_id]);
|
||||
if (current && parsed.activeTimestamp <= current.activeTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
existing.channel = parsed.channel;
|
||||
existing.network_name = parsed.networkName;
|
||||
existing.pan_id = parsed.panId;
|
||||
DATASET_TLVS[existing.dataset_id] = msg.tlv.toUpperCase();
|
||||
return undefined;
|
||||
}
|
||||
added += 1;
|
||||
const dataset: ThreadDataSet = {
|
||||
channel: parsed.channel,
|
||||
created: new Date().toISOString(),
|
||||
dataset_id: `added-dataset-${added}`,
|
||||
extended_pan_id: parsed.extendedPanId,
|
||||
network_name: parsed.networkName,
|
||||
pan_id: parsed.panId,
|
||||
preferred_border_agent_id: null,
|
||||
preferred_extended_address: null,
|
||||
preferred: false,
|
||||
source: msg.source,
|
||||
};
|
||||
DATASETS.push(dataset);
|
||||
DATASET_TLVS[dataset.dataset_id] = msg.tlv.toUpperCase();
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("thread/delete_dataset", (msg: { dataset_id: string }) => {
|
||||
const index = DATASETS.findIndex(
|
||||
(dataset) => dataset.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (index === -1) {
|
||||
throw new Error(`Dataset ${msg.dataset_id} not found`);
|
||||
}
|
||||
if (DATASETS[index].preferred) {
|
||||
throw new Error("Preferred dataset cannot be deleted");
|
||||
}
|
||||
DATASETS.splice(index, 1);
|
||||
delete DATASET_TLVS[msg.dataset_id];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS("thread/set_preferred_dataset", (msg: { dataset_id: string }) => {
|
||||
DATASETS.forEach((dataset) => {
|
||||
dataset.preferred = dataset.dataset_id === msg.dataset_id;
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"thread/set_preferred_border_agent",
|
||||
(msg: {
|
||||
dataset_id: string;
|
||||
border_agent_id: string | null;
|
||||
extended_address: string;
|
||||
}) => {
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (dataset) {
|
||||
dataset.preferred_border_agent_id = msg.border_agent_id;
|
||||
dataset.preferred_extended_address = msg.extended_address;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("otbr/create_network", (msg: { extended_address: string }) => {
|
||||
created += 1;
|
||||
const dataset: ThreadDataSet = {
|
||||
channel: 15,
|
||||
created: new Date().toISOString(),
|
||||
dataset_id: `created-dataset-${created}`,
|
||||
extended_pan_id: randomExtendedPanId(),
|
||||
network_name: `ha-thread-${created}`,
|
||||
pan_id: "1234",
|
||||
preferred_border_agent_id: null,
|
||||
preferred_extended_address: null,
|
||||
preferred: false,
|
||||
source: "otbr",
|
||||
};
|
||||
DATASETS.push(dataset);
|
||||
DATASET_TLVS[dataset.dataset_id] = buildDatasetTlv(dataset);
|
||||
moveRouter(
|
||||
msg.extended_address,
|
||||
dataset.extended_pan_id,
|
||||
dataset.network_name,
|
||||
dataset.dataset_id
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"otbr/set_network",
|
||||
(msg: { extended_address: string; dataset_id: string }) => {
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (dataset) {
|
||||
const info = OTBR_INFO[msg.extended_address];
|
||||
if (info) {
|
||||
info.channel = dataset.channel ?? info.channel;
|
||||
}
|
||||
moveRouter(
|
||||
msg.extended_address,
|
||||
dataset.extended_pan_id,
|
||||
dataset.network_name,
|
||||
dataset.dataset_id
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"otbr/set_channel",
|
||||
(msg: { extended_address: string; channel: number }) => {
|
||||
const info = OTBR_INFO[msg.extended_address];
|
||||
if (info) {
|
||||
info.channel = msg.channel;
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.extended_pan_id === info.extended_pan_id
|
||||
);
|
||||
if (dataset) {
|
||||
dataset.channel = msg.channel;
|
||||
DATASET_TLVS[dataset.dataset_id] = replaceTlvField(
|
||||
DATASET_TLVS[dataset.dataset_id],
|
||||
0x00,
|
||||
`00${msg.channel.toString(16).padStart(4, "0")}`
|
||||
);
|
||||
info.active_dataset_tlvs = DATASET_TLVS[dataset.dataset_id];
|
||||
}
|
||||
}
|
||||
return { delay: 120 };
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ConfigEntry } from "../../../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type {
|
||||
IntegrationManifest,
|
||||
IntegrationType,
|
||||
} from "../../../../src/data/integration";
|
||||
import type { TranslationCategory } from "../../../../src/data/translation";
|
||||
import type { EntityInput } from "../../../../src/fake_data/entities/types";
|
||||
|
||||
export interface DemoConfigEntry {
|
||||
entry: ConfigEntry;
|
||||
type: IntegrationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one connectivity integration contributes to the demo besides its
|
||||
* WebSocket mocks. This is loaded eagerly, together with the registries, so it
|
||||
* must not pull in the mocks (which are code-split into the config panel
|
||||
* chunk). Each integration owns one of these, so they stay independent.
|
||||
*/
|
||||
export interface ConnectivityFixtures {
|
||||
/** Components to load, so the integration's panel is reachable. */
|
||||
components: string[];
|
||||
/** WS command prefixes served by the integration's mock, if it has one. */
|
||||
commands?: string[];
|
||||
configEntries?: DemoConfigEntry[];
|
||||
/** Manifests for the domains above, so their integration pages open. */
|
||||
manifests?: IntegrationManifest[];
|
||||
devices?: DeviceRegistryEntry[];
|
||||
entityRegistryEntries?: EntityRegistryEntry[];
|
||||
/** States for the entities above; built lazily so timestamps stay fresh. */
|
||||
entities?: () => EntityInput[];
|
||||
/** Backend translations the panel looks up, by category. */
|
||||
backendTranslations?: Partial<
|
||||
Record<TranslationCategory, Record<string, string>>
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-zha";
|
||||
|
||||
export const COORDINATOR_IEEE = "00:12:4b:00:24:c2:e1:00";
|
||||
export const PORCH_IEEE = "84:2e:14:ff:fe:11:22:33";
|
||||
export const MOTION_IEEE = "cc:cc:cc:ff:fe:44:55:66";
|
||||
export const PLUG_IEEE = "00:15:8d:00:03:aa:bb:cc";
|
||||
export const KITCHEN_IEEE = "84:2e:14:ff:fe:aa:bb:01";
|
||||
export const LANDING_IEEE = "00:15:8d:00:03:aa:bb:02";
|
||||
export const GARAGE_IEEE = "00:15:8d:00:03:aa:bb:03";
|
||||
export const OFFICE_IEEE = "00:15:8d:00:03:aa:bb:04";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"zha-coordinator",
|
||||
"Home Assistant Connect ZBT-1",
|
||||
"Nabu Casa",
|
||||
"Connect ZBT-1",
|
||||
ENTRY_ID,
|
||||
{
|
||||
sw_version: "7.4.4.0",
|
||||
connections: [["zigbee", COORDINATOR_IEEE]],
|
||||
identifiers: [["zha", COORDINATOR_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-porch-light",
|
||||
"Porch light",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI bulb E27 CWS 806lm",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", PORCH_IEEE]],
|
||||
identifiers: [["zha", PORCH_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-hall-motion",
|
||||
"Hall motion",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI motion sensor",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", MOTION_IEEE]],
|
||||
identifiers: [["zha", MOTION_IEEE]],
|
||||
}
|
||||
),
|
||||
device("zha-tv-plug", "TV plug", "Innr", "SP 220", ENTRY_ID, {
|
||||
area_id: "living_room",
|
||||
connections: [["zigbee", PLUG_IEEE]],
|
||||
identifiers: [["zha", PLUG_IEEE]],
|
||||
}),
|
||||
device(
|
||||
"zha-kitchen-switch",
|
||||
"Kitchen switch",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI on/off switch",
|
||||
ENTRY_ID,
|
||||
{
|
||||
area_id: "kitchen",
|
||||
connections: [["zigbee", KITCHEN_IEEE]],
|
||||
identifiers: [["zha", KITCHEN_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-landing-sensor",
|
||||
"Landing sensor",
|
||||
"Aqara",
|
||||
"WSDCGQ11LM",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", LANDING_IEEE]],
|
||||
identifiers: [["zha", LANDING_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-garage-contact",
|
||||
"Garage contact",
|
||||
"Aqara",
|
||||
"MCCGQ11LM",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", GARAGE_IEEE]],
|
||||
identifiers: [["zha", GARAGE_IEEE]],
|
||||
}
|
||||
),
|
||||
device("zha-office-plug", "Office plug", "Innr", "SP 240", ENTRY_ID, {
|
||||
area_id: "office",
|
||||
connections: [["zigbee", OFFICE_IEEE]],
|
||||
identifiers: [["zha", OFFICE_IEEE]],
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry("light.porch", "zha-porch-light", ENTRY_ID, "zha"),
|
||||
registryEntry(
|
||||
"binary_sensor.hall_motion",
|
||||
"zha-hall-motion",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry("switch.tv_plug", "zha-tv-plug", ENTRY_ID, "zha"),
|
||||
registryEntry(
|
||||
"sensor.kitchen_switch_battery",
|
||||
"zha-kitchen-switch",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.landing_temperature",
|
||||
"zha-landing-sensor",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry(
|
||||
"binary_sensor.garage_contact",
|
||||
"zha-garage-contact",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry("switch.office_desk", "zha-office-plug", ENTRY_ID, "zha"),
|
||||
];
|
||||
|
||||
export const AREA_BY_IEEE: Record<string, string> = Object.fromEntries(
|
||||
DEVICES.flatMap((entry) => {
|
||||
const areaId = entry.area_id;
|
||||
return areaId
|
||||
? entry.connections
|
||||
.filter(([type]) => type === "zigbee")
|
||||
.map(([, ieee]) => [ieee, areaId])
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
export const zhaFixtures: ConnectivityFixtures = {
|
||||
components: ["zha"],
|
||||
commands: ["zha/"],
|
||||
manifests: [
|
||||
manifest("zha", "Zigbee Home Automation", {
|
||||
integration_type: "hub",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "zha", "Home Assistant Connect ZBT-1", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.porch": {
|
||||
entity_id: "light.porch",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Porch light",
|
||||
supported_color_modes: ["hs"],
|
||||
},
|
||||
},
|
||||
"binary_sensor.hall_motion": {
|
||||
entity_id: "binary_sensor.hall_motion",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Hall motion", device_class: "motion" },
|
||||
},
|
||||
"switch.tv_plug": {
|
||||
entity_id: "switch.tv_plug",
|
||||
state: "unavailable",
|
||||
attributes: { friendly_name: "TV plug" },
|
||||
},
|
||||
"sensor.kitchen_switch_battery": {
|
||||
entity_id: "sensor.kitchen_switch_battery",
|
||||
state: "78",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen switch battery",
|
||||
device_class: "battery",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"sensor.landing_temperature": {
|
||||
entity_id: "sensor.landing_temperature",
|
||||
state: "19.6",
|
||||
attributes: {
|
||||
friendly_name: "Landing temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"binary_sensor.garage_contact": {
|
||||
entity_id: "binary_sensor.garage_contact",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Garage contact", device_class: "door" },
|
||||
},
|
||||
"switch.office_desk": {
|
||||
entity_id: "switch.office_desk",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Office desk" },
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
config_panel: {
|
||||
"component.zha.config_panel.zha_options.title": "Global options",
|
||||
"component.zha.config_panel.zha_options.default_light_transition":
|
||||
"Default light transition time (seconds)",
|
||||
"component.zha.config_panel.zha_options.enhanced_light_transition":
|
||||
"Enable enhanced light color/temperature transition from an off state",
|
||||
"component.zha.config_panel.zha_options.always_prefer_xy_color_mode":
|
||||
"Always prefer XY color mode",
|
||||
"component.zha.config_panel.zha_alarm_options.title": "Alarm options",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_master_code":
|
||||
"Alarm master code",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_failed_tries":
|
||||
"Failed authentication attempts before restart",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_arm_requires_code":
|
||||
"Code required for arming",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,793 @@
|
||||
import type {
|
||||
Attribute,
|
||||
AttributeConfigurationStatus,
|
||||
Cluster,
|
||||
ClusterConfigurationEvent,
|
||||
Command,
|
||||
Neighbor,
|
||||
ReadAttributeServiceData,
|
||||
ZHAConfiguration,
|
||||
ZHADevice,
|
||||
ZHADeviceEndpoint,
|
||||
ZHAGroup,
|
||||
ZHAEntityReference,
|
||||
ZHAGroupMember,
|
||||
ZHANetworkBackup,
|
||||
ZHANetworkSettings,
|
||||
} from "../../../../../src/data/zha";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { minutesAgo } from "../helpers";
|
||||
|
||||
import {
|
||||
AREA_BY_IEEE,
|
||||
LANDING_IEEE,
|
||||
COORDINATOR_IEEE,
|
||||
GARAGE_IEEE,
|
||||
KITCHEN_IEEE,
|
||||
MOTION_IEEE,
|
||||
OFFICE_IEEE,
|
||||
PLUG_IEEE,
|
||||
PORCH_IEEE,
|
||||
} from "./fixtures";
|
||||
|
||||
const neighbor = (
|
||||
ieee: string,
|
||||
nwk: string,
|
||||
lqi: string,
|
||||
relationship: string,
|
||||
depth = "1"
|
||||
): Neighbor => ({ ieee, nwk, lqi, depth, relationship });
|
||||
|
||||
const DEVICES: ZHADevice[] = [
|
||||
{
|
||||
available: true,
|
||||
name: "Nabu Casa Connect ZBT-1",
|
||||
ieee: COORDINATOR_IEEE,
|
||||
nwk: 0x0000,
|
||||
lqi: 255,
|
||||
rssi: "0",
|
||||
last_seen: minutesAgo(0),
|
||||
manufacturer: "Nabu Casa",
|
||||
model: "Connect ZBT-1",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-coordinator",
|
||||
user_given_name: "Home Assistant Connect ZBT-1",
|
||||
power_source: "Mains",
|
||||
device_type: "Coordinator",
|
||||
active_coordinator: true,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(PORCH_IEEE, "0x1a2b", "224", "Child"),
|
||||
neighbor(PLUG_IEEE, "0x3c4d", "198", "Child"),
|
||||
neighbor(OFFICE_IEEE, "0x7a8b", "211", "Child"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI bulb E27 CWS 806lm",
|
||||
ieee: PORCH_IEEE,
|
||||
nwk: 0x1a2b,
|
||||
lqi: 224,
|
||||
rssi: "-58",
|
||||
last_seen: minutesAgo(2),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI bulb E27 CWS 806lm",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.ikea.bulb.IkeaBulb",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-porch-light",
|
||||
user_given_name: "Porch light",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(COORDINATOR_IEEE, "0x0000", "224", "Parent", "0"),
|
||||
neighbor(MOTION_IEEE, "0x5e6f", "142", "Child", "2"),
|
||||
neighbor(KITCHEN_IEEE, "0x9c0d", "186", "Sibling", "1"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI motion sensor",
|
||||
ieee: MOTION_IEEE,
|
||||
nwk: 0x5e6f,
|
||||
lqi: 142,
|
||||
rssi: "-77",
|
||||
last_seen: minutesAgo(9),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI motion sensor",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-hall-motion",
|
||||
user_given_name: "Hall motion",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: false,
|
||||
name: "Innr SP 220",
|
||||
ieee: PLUG_IEEE,
|
||||
nwk: 0x3c4d,
|
||||
lqi: 198,
|
||||
rssi: "-64",
|
||||
last_seen: minutesAgo(240),
|
||||
manufacturer: "Innr",
|
||||
model: "SP 220",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4448,
|
||||
device_reg_id: "zha-tv-plug",
|
||||
user_given_name: "TV plug",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI on/off switch",
|
||||
ieee: KITCHEN_IEEE,
|
||||
nwk: 0x9c0d,
|
||||
lqi: 186,
|
||||
rssi: "-69",
|
||||
last_seen: minutesAgo(4),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI on/off switch",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.ikea.onoffswitch.IkeaSwitch",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-kitchen-switch",
|
||||
user_given_name: "Kitchen switch",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Aqara temperature sensor",
|
||||
ieee: LANDING_IEEE,
|
||||
nwk: 0xab12,
|
||||
lqi: 164,
|
||||
rssi: "-74",
|
||||
last_seen: minutesAgo(6),
|
||||
manufacturer: "Aqara",
|
||||
model: "WSDCGQ11LM",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.xiaomi.aqara.weather.Weather",
|
||||
entities: [],
|
||||
manufacturer_code: 4447,
|
||||
device_reg_id: "zha-landing-sensor",
|
||||
user_given_name: "Landing sensor",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Aqara door sensor",
|
||||
ieee: GARAGE_IEEE,
|
||||
nwk: 0xcd34,
|
||||
lqi: 118,
|
||||
rssi: "-83",
|
||||
last_seen: minutesAgo(21),
|
||||
manufacturer: "Aqara",
|
||||
model: "MCCGQ11LM",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.xiaomi.aqara.magnet.Magnet",
|
||||
entities: [],
|
||||
manufacturer_code: 4447,
|
||||
device_reg_id: "zha-garage-contact",
|
||||
user_given_name: "Garage contact",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Innr SP 240",
|
||||
ieee: OFFICE_IEEE,
|
||||
nwk: 0x7a8b,
|
||||
lqi: 211,
|
||||
rssi: "-61",
|
||||
last_seen: minutesAgo(1),
|
||||
manufacturer: "Innr",
|
||||
model: "SP 240",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4448,
|
||||
device_reg_id: "zha-office-plug",
|
||||
user_given_name: "Office plug",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(COORDINATOR_IEEE, "0x0000", "211", "Parent", "0"),
|
||||
neighbor(LANDING_IEEE, "0xab12", "164", "Child", "2"),
|
||||
neighbor(GARAGE_IEEE, "0xcd34", "118", "Child", "2"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
];
|
||||
|
||||
DEVICES.forEach((zhaDevice) => {
|
||||
zhaDevice.area_id = AREA_BY_IEEE[zhaDevice.ieee];
|
||||
});
|
||||
|
||||
const deviceByIeee = (ieee: string): ZHADevice =>
|
||||
DEVICES.find((d) => d.ieee === ieee)!;
|
||||
|
||||
const ENDPOINT_ENTITIES: Record<string, { entity_id: string; name: string }[]> =
|
||||
{
|
||||
[PORCH_IEEE]: [{ entity_id: "light.porch", name: "Porch light" }],
|
||||
[PLUG_IEEE]: [{ entity_id: "switch.tv_plug", name: "TV plug" }],
|
||||
[OFFICE_IEEE]: [{ entity_id: "switch.office_desk", name: "Office desk" }],
|
||||
};
|
||||
|
||||
const member = (ieee: string, endpointId = 1): ZHADeviceEndpoint => ({
|
||||
device: deviceByIeee(ieee),
|
||||
endpoint_id: endpointId,
|
||||
entities: (ENDPOINT_ENTITIES[ieee] ?? []).map(
|
||||
(entity) =>
|
||||
({
|
||||
...entity,
|
||||
original_name: entity.name,
|
||||
}) as ZHAEntityReference
|
||||
),
|
||||
});
|
||||
|
||||
const GROUPS: ZHAGroup[] = [
|
||||
{
|
||||
name: "Downstairs lights",
|
||||
group_id: 1,
|
||||
members: [member(PORCH_IEEE), member(OFFICE_IEEE)],
|
||||
},
|
||||
{
|
||||
name: "Outdoor lights",
|
||||
group_id: 2,
|
||||
members: [member(PORCH_IEEE)],
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGURATION: ZHAConfiguration = {
|
||||
data: {
|
||||
zha_options: {
|
||||
default_light_transition: 0,
|
||||
enhanced_light_transition: false,
|
||||
light_transitioning_flag: true,
|
||||
always_prefer_xy_color_mode: true,
|
||||
group_members_assume_state: true,
|
||||
consider_unavailable_mains: 7200,
|
||||
consider_unavailable_battery: 21600,
|
||||
},
|
||||
zha_alarm_options: {
|
||||
alarm_master_code: "1234",
|
||||
alarm_failed_tries: 3,
|
||||
alarm_arm_requires_code: false,
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
zha_options: [
|
||||
{
|
||||
name: "default_light_transition",
|
||||
required: true,
|
||||
selector: { number: { min: 0, max: 2 ** 16 / 10, step: 0.1 } },
|
||||
},
|
||||
{
|
||||
name: "enhanced_light_transition",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "always_prefer_xy_color_mode",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
zha_alarm_options: [
|
||||
{ name: "alarm_master_code", required: true, selector: { text: {} } },
|
||||
{
|
||||
name: "alarm_failed_tries",
|
||||
required: true,
|
||||
selector: { number: { min: 0, max: 2 ** 8, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "alarm_arm_requires_code",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const BACKUPS: ZHANetworkBackup[] = [];
|
||||
|
||||
const NETWORK_SETTINGS: ZHANetworkSettings = {
|
||||
radio_type: "ezsp",
|
||||
device: { path: "/dev/ttyUSB0", baudrate: 115200, flow_control: "hardware" },
|
||||
settings: {
|
||||
backup_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
node_info: {
|
||||
nwk: "0x0000",
|
||||
ieee: COORDINATOR_IEEE,
|
||||
logical_type: "coordinator",
|
||||
},
|
||||
network_info: {
|
||||
extended_pan_id: "b0:23:2f:cc:aa:11:22:33",
|
||||
pan_id: "0x1234",
|
||||
nwk_update_id: 0,
|
||||
nwk_manager_id: "0x0000",
|
||||
channel: 15,
|
||||
channel_mask: [15, 20, 25],
|
||||
security_level: 5,
|
||||
network_key: {
|
||||
key: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
tx_counter: 138234,
|
||||
rx_counter: 0,
|
||||
seq: 0,
|
||||
partner_ieee: "ff:ff:ff:ff:ff:ff:ff:ff",
|
||||
},
|
||||
tc_link_key: {
|
||||
key: "5a:69:67:42:65:65:41:6c:6c:69:61:6e:63:65:30:39",
|
||||
tx_counter: 0,
|
||||
rx_counter: 0,
|
||||
seq: 0,
|
||||
partner_ieee: COORDINATOR_IEEE,
|
||||
},
|
||||
key_table: [],
|
||||
children: [PORCH_IEEE, PLUG_IEEE, OFFICE_IEEE],
|
||||
nwk_addresses: {
|
||||
[PORCH_IEEE]: "0x1a2b",
|
||||
[MOTION_IEEE]: "0x5e6f",
|
||||
[PLUG_IEEE]: "0x3c4d",
|
||||
[KITCHEN_IEEE]: "0x9c0d",
|
||||
[LANDING_IEEE]: "0xab12",
|
||||
[GARAGE_IEEE]: "0xcd34",
|
||||
[OFFICE_IEEE]: "0x7a8b",
|
||||
},
|
||||
stack_specific: {},
|
||||
metadata: { ezsp: { stack_version: "7.4.4.0" } },
|
||||
source: "[email protected]",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface ClusterDefinition {
|
||||
name: string;
|
||||
attributes: Attribute[];
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
const numberField = (name: string, max: number) => ({
|
||||
name,
|
||||
required: true,
|
||||
selector: { number: { min: 0, max, mode: "box" as const } },
|
||||
});
|
||||
|
||||
const CLUSTERS: Record<number, ClusterDefinition> = {
|
||||
0: {
|
||||
name: "Basic",
|
||||
attributes: [
|
||||
{ name: "zcl_version", id: 0 },
|
||||
{ name: "app_version", id: 1 },
|
||||
{ name: "manufacturer", id: 4 },
|
||||
{ name: "model", id: 5 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
1: {
|
||||
name: "PowerConfiguration",
|
||||
attributes: [
|
||||
{ name: "battery_voltage", id: 32 },
|
||||
{ name: "battery_percentage_remaining", id: 33 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
3: {
|
||||
name: "Identify",
|
||||
attributes: [{ name: "identify_time", id: 0 }],
|
||||
commands: [
|
||||
{
|
||||
name: "identify",
|
||||
id: 0,
|
||||
type: "server",
|
||||
schema: [numberField("identify_time", 65535)],
|
||||
},
|
||||
],
|
||||
},
|
||||
4: {
|
||||
name: "Groups",
|
||||
attributes: [{ name: "name_support", id: 0 }],
|
||||
commands: [],
|
||||
},
|
||||
6: {
|
||||
name: "OnOff",
|
||||
attributes: [{ name: "on_off", id: 0 }],
|
||||
commands: [
|
||||
{ name: "off", id: 0, type: "server", schema: [] },
|
||||
{ name: "on", id: 1, type: "server", schema: [] },
|
||||
{ name: "toggle", id: 2, type: "server", schema: [] },
|
||||
],
|
||||
},
|
||||
8: {
|
||||
name: "LevelControl",
|
||||
attributes: [{ name: "current_level", id: 0 }],
|
||||
commands: [
|
||||
{
|
||||
name: "move_to_level",
|
||||
id: 0,
|
||||
type: "server",
|
||||
schema: [
|
||||
numberField("level", 254),
|
||||
numberField("transition_time", 65535),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
25: {
|
||||
name: "Ota",
|
||||
attributes: [{ name: "current_file_version", id: 2 }],
|
||||
commands: [],
|
||||
},
|
||||
768: {
|
||||
name: "ColorControl",
|
||||
attributes: [
|
||||
{ name: "current_hue", id: 0 },
|
||||
{ name: "current_saturation", id: 1 },
|
||||
{ name: "color_temperature", id: 7 },
|
||||
],
|
||||
commands: [
|
||||
{
|
||||
name: "move_to_color_temp",
|
||||
id: 10,
|
||||
type: "server",
|
||||
schema: [
|
||||
numberField("color_temp_mireds", 500),
|
||||
numberField("transition_time", 65535),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
1026: {
|
||||
name: "TemperatureMeasurement",
|
||||
attributes: [
|
||||
{ name: "measured_value", id: 0 },
|
||||
{ name: "min_measured_value", id: 1 },
|
||||
{ name: "max_measured_value", id: 2 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
1280: {
|
||||
name: "IasZone",
|
||||
attributes: [
|
||||
{ name: "zone_state", id: 0 },
|
||||
{ name: "zone_type", id: 1 },
|
||||
{ name: "zone_status", id: 2 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
2820: {
|
||||
name: "ElectricalMeasurement",
|
||||
attributes: [
|
||||
{ name: "rms_voltage", id: 1285 },
|
||||
{ name: "rms_current", id: 1288 },
|
||||
{ name: "active_power", id: 1291 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
};
|
||||
|
||||
const clusterList = (inIds: number[], outIds: number[] = []): Cluster[] =>
|
||||
[
|
||||
...inIds.map((id) => ({ id, type: "in" })),
|
||||
...outIds.map((id) => ({ id, type: "out" })),
|
||||
].map(({ id, type }) => ({
|
||||
name: CLUSTERS[id].name,
|
||||
id,
|
||||
endpoint_id: 1,
|
||||
type,
|
||||
}));
|
||||
|
||||
// A device binds from its client side, and group binding lists only those, so
|
||||
// the remotes carry the `out` clusters they would have on real hardware and
|
||||
// the mains-powered devices only their OTA one.
|
||||
const DEVICE_CLUSTERS: Record<string, Cluster[]> = {
|
||||
[COORDINATOR_IEEE]: clusterList([0]),
|
||||
[PORCH_IEEE]: clusterList([0, 3, 4, 6, 8, 768], [25]),
|
||||
[MOTION_IEEE]: clusterList([0, 1, 3, 1280], [3, 6, 8]),
|
||||
[PLUG_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
|
||||
[KITCHEN_IEEE]: clusterList([0, 1, 3], [3, 6, 8]),
|
||||
[LANDING_IEEE]: clusterList([0, 1, 3, 1026]),
|
||||
[GARAGE_IEEE]: clusterList([0, 1, 3, 1280]),
|
||||
[OFFICE_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
|
||||
};
|
||||
|
||||
const DEFAULT_ATTRIBUTE_VALUES: Record<string, string> = {
|
||||
"1:32": "30",
|
||||
"1:33": "184",
|
||||
"3:0": "0",
|
||||
"4:0": "0",
|
||||
"6:0": "1",
|
||||
"8:0": "254",
|
||||
"768:0": "42",
|
||||
"768:1": "180",
|
||||
"768:7": "370",
|
||||
"1026:0": "2140",
|
||||
"1026:1": "-2000",
|
||||
"1026:2": "6000",
|
||||
"1280:0": "1",
|
||||
"1280:1": "21",
|
||||
"1280:2": "0",
|
||||
"2820:1285": "2300",
|
||||
"2820:1288": "410",
|
||||
"2820:1291": "94",
|
||||
};
|
||||
|
||||
const writtenAttributes = new Map<string, string>();
|
||||
|
||||
const attributeKey = (data: ReadAttributeServiceData) =>
|
||||
`${data.ieee}:${data.endpoint_id}:${data.cluster_id}:${data.attribute}`;
|
||||
|
||||
const attributeValue = (data: ReadAttributeServiceData): string => {
|
||||
const written = writtenAttributes.get(attributeKey(data));
|
||||
if (written !== undefined) {
|
||||
return written;
|
||||
}
|
||||
if (data.cluster_id === 0) {
|
||||
const device = DEVICES.find((candidate) => candidate.ieee === data.ieee);
|
||||
if (data.attribute === 4) {
|
||||
return device?.manufacturer ?? "";
|
||||
}
|
||||
if (data.attribute === 5) {
|
||||
return device?.model ?? "";
|
||||
}
|
||||
return "3";
|
||||
}
|
||||
return (
|
||||
DEFAULT_ATTRIBUTE_VALUES[`${data.cluster_id}:${data.attribute}`] ?? "0"
|
||||
);
|
||||
};
|
||||
|
||||
export const mockZha = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zha/devices", () => DEVICES);
|
||||
hass.mockWS("zha/device", (msg: { ieee: string }) =>
|
||||
DEVICES.find((device) => device.ieee === msg.ieee)
|
||||
);
|
||||
hass.mockWS("zha/groups", () => GROUPS);
|
||||
hass.mockWS("zha/group", (msg: { group_id: number }) =>
|
||||
GROUPS.find((group) => group.group_id === msg.group_id)
|
||||
);
|
||||
// Copied: both options editors mutate the fetched data as the user changes a
|
||||
// control, so handing out the backing object would persist edits that were
|
||||
// never saved. Only the update below writes to it.
|
||||
hass.mockWS("zha/configuration", () => structuredClone(CONFIGURATION));
|
||||
hass.mockWS("zha/network/settings", () => NETWORK_SETTINGS);
|
||||
hass.mockWS("zha/topology/update", () => undefined);
|
||||
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
|
||||
DEVICES.filter(
|
||||
(candidate) =>
|
||||
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
|
||||
)
|
||||
);
|
||||
hass.mockWS("zha/devices/bind", () => undefined);
|
||||
hass.mockWS("zha/devices/unbind", () => undefined);
|
||||
hass.mockWS("zha/groups/bind", () => undefined);
|
||||
hass.mockWS("zha/groups/unbind", () => undefined);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters",
|
||||
(msg: { ieee: string }) => DEVICE_CLUSTERS[msg.ieee] ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/attributes",
|
||||
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.attributes ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/commands",
|
||||
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.commands ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/attributes/value",
|
||||
(msg: ReadAttributeServiceData) => attributeValue(msg)
|
||||
);
|
||||
hass.mockService("zha", "set_zigbee_cluster_attribute", (data) => {
|
||||
const write = data as ReadAttributeServiceData & { value: unknown };
|
||||
writtenAttributes.set(attributeKey(write), String(write.value));
|
||||
return undefined;
|
||||
});
|
||||
hass.mockWS("zha/devices/groupable", () => [
|
||||
member(PORCH_IEEE),
|
||||
member(OFFICE_IEEE),
|
||||
member(PLUG_IEEE),
|
||||
]);
|
||||
hass.mockWS("zha/network/backups/list", () => BACKUPS);
|
||||
|
||||
hass.mockWS("zha/devices/permit", () => () => undefined);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/devices/reconfigure",
|
||||
(msg: { ieee: string }, _hass, onChange) => {
|
||||
const deviceClusters = DEVICE_CLUSTERS[msg.ieee] ?? [];
|
||||
const timers: number[] = [];
|
||||
let cancelled = false;
|
||||
const emit = (event: ClusterConfigurationEvent, step: number) => {
|
||||
timers.push(
|
||||
window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
onChange!(event);
|
||||
}
|
||||
}, step * 400)
|
||||
);
|
||||
};
|
||||
|
||||
deviceClusters.forEach((cluster, index) => {
|
||||
emit(
|
||||
{
|
||||
type: "zha_channel_bind",
|
||||
zha_channel_msg_data: {
|
||||
cluster_name: cluster.name,
|
||||
cluster_id: cluster.id,
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
index + 1
|
||||
);
|
||||
const attributes: AttributeConfigurationStatus[] = CLUSTERS[
|
||||
cluster.id
|
||||
].attributes.map((attribute) => ({
|
||||
...attribute,
|
||||
status: "SUCCESS",
|
||||
min: 30,
|
||||
max: 900,
|
||||
change: 1,
|
||||
}));
|
||||
if (attributes.length) {
|
||||
emit(
|
||||
{
|
||||
type: "zha_channel_configure_reporting",
|
||||
zha_channel_msg_data: {
|
||||
cluster_name: cluster.name,
|
||||
cluster_id: cluster.id,
|
||||
attributes,
|
||||
},
|
||||
},
|
||||
index + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
emit({ type: "zha_channel_cfg_done" }, deviceClusters.length + 1);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/configuration/update",
|
||||
(msg: { data: ZHAConfiguration["data"] }) => {
|
||||
Object.entries(msg.data ?? {}).forEach(([section, values]) => {
|
||||
CONFIGURATION.data[section] = {
|
||||
...CONFIGURATION.data[section],
|
||||
...values,
|
||||
};
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("zha/network/backups/create", () => {
|
||||
const backup: ZHANetworkBackup = {
|
||||
backup_time: new Date().toISOString(),
|
||||
// Copied, or changing the channel afterwards would rewrite the backup
|
||||
// too, which is the one thing a backup must not do.
|
||||
network_info: structuredClone(NETWORK_SETTINGS.settings.network_info),
|
||||
node_info: structuredClone(NETWORK_SETTINGS.settings.node_info),
|
||||
};
|
||||
BACKUPS.push(backup);
|
||||
return { backup, is_complete: true };
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"zha/network/change_channel",
|
||||
(msg: { new_channel: "auto" | number }) => {
|
||||
NETWORK_SETTINGS.settings.network_info.channel =
|
||||
msg.new_channel === "auto" ? 25 : msg.new_channel;
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/add",
|
||||
(msg: {
|
||||
group_name: string;
|
||||
group_id?: number;
|
||||
members?: ZHAGroupMember[];
|
||||
}) => {
|
||||
const group: ZHAGroup = {
|
||||
name: msg.group_name,
|
||||
group_id:
|
||||
msg.group_id ??
|
||||
GROUPS.reduce(
|
||||
(highest, item) => Math.max(highest, item.group_id),
|
||||
0
|
||||
) + 1,
|
||||
members: (msg.members ?? []).map((item) => member(item.ieee)),
|
||||
};
|
||||
GROUPS.push(group);
|
||||
return group;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("zha/group/remove", (msg: { group_ids: number[] }) => {
|
||||
msg.group_ids.forEach((groupId) => {
|
||||
const index = GROUPS.findIndex((group) => group.group_id === groupId);
|
||||
if (index !== -1) {
|
||||
GROUPS.splice(index, 1);
|
||||
}
|
||||
});
|
||||
return GROUPS;
|
||||
});
|
||||
|
||||
const findGroup = (groupId: number) => {
|
||||
const group = GROUPS.find((candidate) => candidate.group_id === groupId);
|
||||
if (!group) {
|
||||
throw new Error(`Group ${groupId} not found`);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/members/add",
|
||||
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
|
||||
const group = findGroup(msg.group_id);
|
||||
const known = new Set(group.members.map((item) => item.device.ieee));
|
||||
group.members = [
|
||||
...group.members,
|
||||
...msg.members
|
||||
.filter((item) => !known.has(item.ieee))
|
||||
.map((item) => member(item.ieee)),
|
||||
];
|
||||
return group;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/members/remove",
|
||||
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
|
||||
const group = findGroup(msg.group_id);
|
||||
const dropped = new Set(msg.members.map((item) => item.ieee));
|
||||
group.members = group.members.filter(
|
||||
(item) => !dropped.has(item.device.ieee)
|
||||
);
|
||||
return group;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-zwave-js";
|
||||
export const HOME_ID = 3245146787;
|
||||
|
||||
// Node IDs are carried in the `zwave_js` device identifiers, which is where the
|
||||
// panels read them back from.
|
||||
export const CONTROLLER_NODE_ID = 1;
|
||||
export const HALLWAY_NODE_ID = 7;
|
||||
export const DIMMER_NODE_ID = 12;
|
||||
export const MOTION_NODE_ID = 15;
|
||||
export const LOCK_NODE_ID = 18;
|
||||
export const THERMOSTAT_NODE_ID = 20;
|
||||
export const SENSOR_NODE_ID = 23;
|
||||
export const OUTLET_NODE_ID = 31;
|
||||
|
||||
export const DEVICE_IDS_BY_NODE_ID: Record<number, string> = {
|
||||
[CONTROLLER_NODE_ID]: "zwave-controller",
|
||||
[HALLWAY_NODE_ID]: "zwave-hallway-switch",
|
||||
[DIMMER_NODE_ID]: "zwave-dining-dimmer",
|
||||
[MOTION_NODE_ID]: "zwave-garage-motion",
|
||||
[LOCK_NODE_ID]: "zwave-back-door-lock",
|
||||
[THERMOSTAT_NODE_ID]: "zwave-bedroom-thermostat",
|
||||
[SENSOR_NODE_ID]: "zwave-basement-sensor",
|
||||
[OUTLET_NODE_ID]: "zwave-porch-outlet",
|
||||
};
|
||||
|
||||
const identifiers = (nodeId: number): [string, string][] => [
|
||||
["zwave_js", `${HOME_ID}-${nodeId}`],
|
||||
];
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"zwave-controller",
|
||||
"Z-Wave stick",
|
||||
"Zooz",
|
||||
"800 Series Z-Wave Long Range",
|
||||
ENTRY_ID,
|
||||
{ sw_version: "1.10", identifiers: identifiers(CONTROLLER_NODE_ID) }
|
||||
),
|
||||
device(
|
||||
"zwave-dining-dimmer",
|
||||
"Dining room dimmer",
|
||||
"Inovelli",
|
||||
"LZW31-SN",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(DIMMER_NODE_ID) }
|
||||
),
|
||||
device("zwave-back-door-lock", "Back door lock", "Yale", "YRD226", ENTRY_ID, {
|
||||
identifiers: identifiers(LOCK_NODE_ID),
|
||||
}),
|
||||
device(
|
||||
"zwave-basement-sensor",
|
||||
"Basement sensor",
|
||||
"Aeotec",
|
||||
"ZWA005 TriSensor",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(SENSOR_NODE_ID) }
|
||||
),
|
||||
device("zwave-hallway-switch", "Hallway switch", "Zooz", "ZEN76", ENTRY_ID, {
|
||||
area_id: "entrance",
|
||||
identifiers: identifiers(HALLWAY_NODE_ID),
|
||||
}),
|
||||
device(
|
||||
"zwave-garage-motion",
|
||||
"Garage motion",
|
||||
"Aeotec",
|
||||
"MultiSensor 7",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(MOTION_NODE_ID) }
|
||||
),
|
||||
device(
|
||||
"zwave-bedroom-thermostat",
|
||||
"Bedroom thermostat",
|
||||
"Honeywell",
|
||||
"T6 Pro",
|
||||
ENTRY_ID,
|
||||
{ area_id: "bedroom", identifiers: identifiers(THERMOSTAT_NODE_ID) }
|
||||
),
|
||||
device("zwave-porch-outlet", "Porch outlet", "Zooz", "ZEN15", ENTRY_ID, {
|
||||
identifiers: identifiers(OUTLET_NODE_ID),
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"light.dining_room",
|
||||
"zwave-dining-dimmer",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry("lock.back_door", "zwave-back-door-lock", ENTRY_ID, "zwave_js"),
|
||||
registryEntry(
|
||||
"sensor.basement_humidity",
|
||||
"zwave-basement-sensor",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry("light.hallway", "zwave-hallway-switch", ENTRY_ID, "zwave_js"),
|
||||
registryEntry(
|
||||
"binary_sensor.garage_motion",
|
||||
"zwave-garage-motion",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry(
|
||||
"climate.bedroom",
|
||||
"zwave-bedroom-thermostat",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry(
|
||||
"switch.porch_outlet",
|
||||
"zwave-porch-outlet",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
];
|
||||
|
||||
export const zwaveJsFixtures: ConnectivityFixtures = {
|
||||
components: ["zwave_js"],
|
||||
commands: ["zwave_js/"],
|
||||
manifests: [manifest("zwave_js", "Z-Wave", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "zwave_js", "Z-Wave", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.dining_room": {
|
||||
entity_id: "light.dining_room",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Dining room dimmer",
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 128,
|
||||
},
|
||||
},
|
||||
"lock.back_door": {
|
||||
entity_id: "lock.back_door",
|
||||
state: "unlocked",
|
||||
attributes: { friendly_name: "Back door lock" },
|
||||
},
|
||||
"sensor.basement_humidity": {
|
||||
entity_id: "sensor.basement_humidity",
|
||||
state: "58",
|
||||
attributes: {
|
||||
friendly_name: "Basement humidity",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"light.hallway": {
|
||||
entity_id: "light.hallway",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Hallway switch",
|
||||
supported_color_modes: ["onoff"],
|
||||
},
|
||||
},
|
||||
"binary_sensor.garage_motion": {
|
||||
entity_id: "binary_sensor.garage_motion",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Garage motion", device_class: "motion" },
|
||||
},
|
||||
"climate.bedroom": {
|
||||
entity_id: "climate.bedroom",
|
||||
state: "heat",
|
||||
attributes: {
|
||||
friendly_name: "Bedroom thermostat",
|
||||
hvac_modes: ["off", "heat"],
|
||||
current_temperature: 19.6,
|
||||
temperature: 20.5,
|
||||
min_temp: 7,
|
||||
max_temp: 30,
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
"switch.porch_outlet": {
|
||||
entity_id: "switch.porch_outlet",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Porch outlet" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import type {
|
||||
ZWaveJSController,
|
||||
ZWaveJSNetwork,
|
||||
ZWaveJSNodeStatisticsUpdatedMessage,
|
||||
ZWaveJSNodeStatus,
|
||||
ZwaveJSProvisioningEntry,
|
||||
} from "../../../../../src/data/zwave_js";
|
||||
import {
|
||||
NodeStatus,
|
||||
ProvisioningEntryStatus,
|
||||
SecurityClass,
|
||||
} from "../../../../../src/data/zwave_js";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
import {
|
||||
CONTROLLER_NODE_ID,
|
||||
DEVICE_IDS_BY_NODE_ID,
|
||||
DIMMER_NODE_ID,
|
||||
HALLWAY_NODE_ID,
|
||||
HOME_ID,
|
||||
LOCK_NODE_ID,
|
||||
MOTION_NODE_ID,
|
||||
OUTLET_NODE_ID,
|
||||
SENSOR_NODE_ID,
|
||||
THERMOSTAT_NODE_ID,
|
||||
} from "./fixtures";
|
||||
|
||||
const node = (
|
||||
nodeId: number,
|
||||
status: NodeStatus,
|
||||
overrides: Partial<ZWaveJSNodeStatus> = {}
|
||||
): ZWaveJSNodeStatus => ({
|
||||
node_id: nodeId,
|
||||
ready: true,
|
||||
status,
|
||||
is_secure: true,
|
||||
is_routing: true,
|
||||
zwave_plus_version: 2,
|
||||
highest_security_class: SecurityClass.S2_Authenticated,
|
||||
is_controller_node: false,
|
||||
has_firmware_update_cc: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const NODES: ZWaveJSNodeStatus[] = [
|
||||
node(CONTROLLER_NODE_ID, NodeStatus.Alive, {
|
||||
is_controller_node: true,
|
||||
highest_security_class: SecurityClass.S2_AccessControl,
|
||||
}),
|
||||
node(HALLWAY_NODE_ID, NodeStatus.Alive),
|
||||
node(DIMMER_NODE_ID, NodeStatus.Alive),
|
||||
node(MOTION_NODE_ID, NodeStatus.Asleep, { is_routing: false }),
|
||||
node(LOCK_NODE_ID, NodeStatus.Asleep, {
|
||||
is_routing: false,
|
||||
highest_security_class: SecurityClass.S2_AccessControl,
|
||||
}),
|
||||
node(THERMOSTAT_NODE_ID, NodeStatus.Alive),
|
||||
node(SENSOR_NODE_ID, NodeStatus.Dead, { is_routing: false }),
|
||||
node(OUTLET_NODE_ID, NodeStatus.Alive),
|
||||
];
|
||||
|
||||
const CONTROLLER: ZWaveJSController = {
|
||||
home_id: HOME_ID,
|
||||
sdk_version: "7.19.3",
|
||||
type: 1,
|
||||
own_node_id: CONTROLLER_NODE_ID,
|
||||
rf_region: null,
|
||||
is_primary: true,
|
||||
is_using_home_id_from_other_network: false,
|
||||
is_sis_present: true,
|
||||
was_real_primary: true,
|
||||
is_suc: true,
|
||||
// NodeType.Controller; the enum itself is not exported from data/zwave_js.
|
||||
node_type: 0 as ZWaveJSController["node_type"],
|
||||
firmware_version: "1.10",
|
||||
manufacturer_id: 634,
|
||||
product_id: 4,
|
||||
product_type: 3,
|
||||
supported_function_types: [],
|
||||
suc_node_id: CONTROLLER_NODE_ID,
|
||||
supports_timers: false,
|
||||
is_rebuilding_routes: false,
|
||||
// InclusionState.Idle
|
||||
inclusion_state: 0,
|
||||
nodes: NODES,
|
||||
supports_long_range: true,
|
||||
};
|
||||
|
||||
const NETWORK: ZWaveJSNetwork = {
|
||||
client: {
|
||||
state: "connected",
|
||||
ws_server_url: "ws://localhost:3000",
|
||||
server_version: "1.40.1",
|
||||
driver_version: "13.2.0",
|
||||
},
|
||||
controller: CONTROLLER,
|
||||
};
|
||||
|
||||
const PROVISIONING_ENTRIES: ZwaveJSProvisioningEntry[] = [
|
||||
{
|
||||
dsk: "51590-27189-49239-34778-15304-59293-52843-45852",
|
||||
securityClasses: [SecurityClass.S2_Authenticated],
|
||||
status: ProvisioningEntryStatus.Active,
|
||||
additionalProperties: {},
|
||||
manufacturer: "Zooz",
|
||||
label: "ZEN32 Scene Controller",
|
||||
},
|
||||
];
|
||||
|
||||
// Node IDs each node can reach directly. Only requested when the map's
|
||||
// neighbor overlay is toggled on.
|
||||
const NEIGHBORS: Record<number, number[]> = {
|
||||
[CONTROLLER_NODE_ID]: [HALLWAY_NODE_ID, DIMMER_NODE_ID, OUTLET_NODE_ID],
|
||||
[HALLWAY_NODE_ID]: [
|
||||
CONTROLLER_NODE_ID,
|
||||
DIMMER_NODE_ID,
|
||||
LOCK_NODE_ID,
|
||||
THERMOSTAT_NODE_ID,
|
||||
],
|
||||
[DIMMER_NODE_ID]: [
|
||||
CONTROLLER_NODE_ID,
|
||||
HALLWAY_NODE_ID,
|
||||
SENSOR_NODE_ID,
|
||||
OUTLET_NODE_ID,
|
||||
],
|
||||
[MOTION_NODE_ID]: [THERMOSTAT_NODE_ID],
|
||||
[LOCK_NODE_ID]: [HALLWAY_NODE_ID],
|
||||
[THERMOSTAT_NODE_ID]: [HALLWAY_NODE_ID, MOTION_NODE_ID],
|
||||
[SENSOR_NODE_ID]: [DIMMER_NODE_ID],
|
||||
[OUTLET_NODE_ID]: [CONTROLLER_NODE_ID, DIMMER_NODE_ID],
|
||||
};
|
||||
|
||||
// Route each node reports as its last working route back to the controller,
|
||||
// so the map can draw the mesh instead of a star.
|
||||
const ROUTES: Record<number, { repeaters: number[]; rssi: number }> = {
|
||||
[HALLWAY_NODE_ID]: { repeaters: [], rssi: -44 },
|
||||
[DIMMER_NODE_ID]: { repeaters: [], rssi: -48 },
|
||||
[OUTLET_NODE_ID]: { repeaters: [], rssi: -57 },
|
||||
[LOCK_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -72 },
|
||||
[THERMOSTAT_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -66 },
|
||||
[MOTION_NODE_ID]: {
|
||||
repeaters: [HALLWAY_NODE_ID, THERMOSTAT_NODE_ID],
|
||||
rssi: -79,
|
||||
},
|
||||
[SENSOR_NODE_ID]: { repeaters: [DIMMER_NODE_ID], rssi: -81 },
|
||||
};
|
||||
|
||||
const NODE_IDS_BY_DEVICE_ID: Record<string, number> = Object.fromEntries(
|
||||
Object.entries(DEVICE_IDS_BY_NODE_ID).map(([nodeId, deviceId]) => [
|
||||
deviceId,
|
||||
Number(nodeId),
|
||||
])
|
||||
);
|
||||
|
||||
const buildNodeStatistics = (
|
||||
nodeId: number
|
||||
): ZWaveJSNodeStatisticsUpdatedMessage => {
|
||||
const route = ROUTES[nodeId];
|
||||
return {
|
||||
event: "statistics updated",
|
||||
source: "node",
|
||||
nodeId,
|
||||
node_id: nodeId,
|
||||
commands_tx: 1200 + nodeId * 7,
|
||||
commands_rx: 980 + nodeId * 5,
|
||||
commands_dropped_tx: 0,
|
||||
commands_dropped_rx: nodeId === SENSOR_NODE_ID ? 4 : 0,
|
||||
timeout_response: 0,
|
||||
rtt: 24 + nodeId,
|
||||
rssi: route?.rssi ?? null,
|
||||
lwr: route
|
||||
? {
|
||||
protocol_data_rate: 3,
|
||||
repeaters: route.repeaters.map(
|
||||
(repeaterNodeId) => DEVICE_IDS_BY_NODE_ID[repeaterNodeId]
|
||||
),
|
||||
rssi: route.rssi,
|
||||
repeater_rssi: route.repeaters.map(() => -55),
|
||||
route_failed_between: null,
|
||||
}
|
||||
: null,
|
||||
nlwr: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockZwaveJs = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zwave_js/network_status", () => NETWORK);
|
||||
hass.mockWS("zwave_js/network_neighbors", () => NEIGHBORS);
|
||||
hass.mockWS("zwave_js/get_provisioning_entries", () => PROVISIONING_ENTRIES);
|
||||
hass.mockWS("zwave_js/data_collection_status", () => ({
|
||||
enabled: false,
|
||||
opted_in: false,
|
||||
}));
|
||||
hass.mockWS("zwave_js/subscribe_s2_inclusion", () => () => undefined);
|
||||
hass.mockWS("zwave_js/node_status", (msg: { device_id: string }) => {
|
||||
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
|
||||
return NODES.find((n) => n.node_id === nodeId) ?? NODES[0];
|
||||
});
|
||||
hass.mockWS(
|
||||
"zwave_js/subscribe_node_statistics",
|
||||
(msg: { device_id: string }, _hass, onChange) => {
|
||||
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
|
||||
if (nodeId === undefined || nodeId === CONTROLLER_NODE_ID) {
|
||||
return () => undefined;
|
||||
}
|
||||
return emitInitial(() => onChange?.(buildNodeStatistics(nodeId)));
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"zwave_js/subscribe_controller_statistics",
|
||||
(_msg, _hass, onChange) =>
|
||||
emitInitial(() =>
|
||||
onChange?.({
|
||||
event: "statistics updated",
|
||||
source: "controller",
|
||||
messages_tx: 18234,
|
||||
messages_rx: 17980,
|
||||
messages_dropped_tx: 2,
|
||||
messages_dropped_rx: 5,
|
||||
nak: 0,
|
||||
can: 3,
|
||||
timeout_ack: 1,
|
||||
timeout_response: 0,
|
||||
timeout_callback: 0,
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import { connectivityDevices } from "./connectivity/fixtures";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
@@ -86,4 +87,5 @@ export const demoDevices: DeviceRegistryEntry[] = [
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
...connectivityDevices,
|
||||
];
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
import { connectivityManifests } from "./connectivity/fixtures";
|
||||
import { manifest } from "./manifest";
|
||||
|
||||
const manifests: IntegrationManifest[] = [
|
||||
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
|
||||
@@ -62,11 +50,18 @@ const manifests: IntegrationManifest[] = [
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
...connectivityManifests,
|
||||
];
|
||||
|
||||
export const mockIntegration = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("manifest/list", () => manifests);
|
||||
hass.mockWS("manifest/get", (msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration)
|
||||
// Never answer with undefined: the integration page reads the manifest it
|
||||
// gets back without guarding, so an unlisted domain would throw. The real
|
||||
// backend always has a manifest for a domain that has config entries.
|
||||
hass.mockWS(
|
||||
"manifest/get",
|
||||
(msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration) ??
|
||||
manifest(msg.integration, msg.integration)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
|
||||
/**
|
||||
* Builds a demo integration manifest. Lives in its own module so both the
|
||||
* manifest registry and the per-integration fixtures that feed it can use it
|
||||
* without importing each other.
|
||||
*/
|
||||
export const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
+54
-2
@@ -1,6 +1,58 @@
|
||||
import type { Tag } from "../../../src/data/tag";
|
||||
import type { Tag, UpdateTagParams } from "../../../src/data/tag";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockTags = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("tag/list", () => [{ id: "my-tag", name: "My Tag" }] as Tag[]);
|
||||
const tags: Tag[] = [{ id: "my-tag", name: "My Tag" }];
|
||||
let created = 0;
|
||||
|
||||
const find = (tagId: string) => tags.find((tag) => tag.id === tagId);
|
||||
|
||||
hass.mockWS("tag/list", () => tags.map((tag) => ({ ...tag })));
|
||||
|
||||
hass.mockWS(
|
||||
"tag/create",
|
||||
(msg: UpdateTagParams & { tag_id?: string }): Tag => {
|
||||
if (msg.tag_id && find(msg.tag_id)) {
|
||||
throw new Error(`Tag ${msg.tag_id} already exists`);
|
||||
}
|
||||
let id = msg.tag_id;
|
||||
while (!id) {
|
||||
created += 1;
|
||||
id = find(`tag-${created}`) ? undefined : `tag-${created}`;
|
||||
}
|
||||
const tag: Tag = {
|
||||
id,
|
||||
name: msg.name,
|
||||
description: msg.description,
|
||||
};
|
||||
tags.push(tag);
|
||||
return { ...tag };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"tag/update",
|
||||
(msg: UpdateTagParams & { tag_id: string }): Tag => {
|
||||
const tag = find(msg.tag_id);
|
||||
if (!tag) {
|
||||
throw new Error(`Tag ${msg.tag_id} not found`);
|
||||
}
|
||||
if ("name" in msg) {
|
||||
tag.name = msg.name;
|
||||
}
|
||||
if ("description" in msg) {
|
||||
tag.description = msg.description;
|
||||
}
|
||||
return { ...tag };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("tag/delete", (msg: { tag_id: string }) => {
|
||||
const index = tags.findIndex((tag) => tag.id === msg.tag_id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Tag ${msg.tag_id} not found`);
|
||||
}
|
||||
tags.splice(index, 1);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityBackendTranslations } from "./connectivity/fixtures";
|
||||
|
||||
export const mockTranslations = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("frontend/get_translations", (
|
||||
/* msg: {language: string, category: string} */
|
||||
) => ({ resources: {} }));
|
||||
export const mockTranslations = (
|
||||
hass: MockHomeAssistant,
|
||||
localizePromise?: Promise<LocalizeFunc>
|
||||
) => {
|
||||
hass.mockWS(
|
||||
"frontend/get_translations",
|
||||
(msg: { language: string; category?: string }) => ({
|
||||
resources:
|
||||
(msg.category && connectivityBackendTranslations[msg.category]) || {},
|
||||
})
|
||||
);
|
||||
|
||||
// `hass.loadBackendTranslation` is a no-op in the mocked hass, so categories
|
||||
// that are only requested through it never reach the WebSocket mock above.
|
||||
// Seed every category into the resources, after the fragment translations so
|
||||
// this merges on top of them.
|
||||
(localizePromise ?? Promise.resolve()).then(() =>
|
||||
hass.addTranslations(
|
||||
Object.assign({}, ...Object.values(connectivityBackendTranslations))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -693,9 +693,7 @@ class HaGallery extends LitElement {
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
user-select: initial;
|
||||
--ha-sidebar-width: 300px;
|
||||
--ha-sidebar-expanded-width: 300px;
|
||||
--ha-sidebar-expanded-item-width: 292px;
|
||||
|
||||
@@ -57,7 +57,6 @@ This component is based on the webawesome input component.
|
||||
| invalid | Boolean | false | Marks the input as invalid. |
|
||||
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
|
||||
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
|
||||
| input-id | String | - | Sets the native input's `id` and associated label `for` on the inner `wa-input`. Defaults to `"input"`. |
|
||||
| pattern | String | - | A regular expression pattern to validate input against. |
|
||||
| minlength | Number | - | The minimum length of input that will be considered valid. |
|
||||
| maxlength | Number | - | The maximum length of input that will be considered valid. |
|
||||
|
||||
@@ -16,6 +16,25 @@ const ENTITIES = [
|
||||
duration: "0:05:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.active_timer",
|
||||
state: "active",
|
||||
attributes: {
|
||||
friendly_name: "Active timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:10:00",
|
||||
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.paused_timer",
|
||||
state: "paused",
|
||||
attributes: {
|
||||
friendly_name: "Paused timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:03:21",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-timer")
|
||||
|
||||
+24
-25
@@ -47,12 +47,12 @@
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.4",
|
||||
"@codemirror/view": "6.43.11",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.6.0",
|
||||
"@formatjs/intl-datetimeformat": "7.6.1",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
@@ -67,7 +67,7 @@
|
||||
"@fullcalendar/list": "6.1.21",
|
||||
"@fullcalendar/luxon3": "6.1.21",
|
||||
"@fullcalendar/timegrid": "6.1.21",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.1",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -85,12 +85,11 @@
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.3.2",
|
||||
"@tsparticles/preset-links": "4.3.2",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
@@ -101,16 +100,15 @@
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.1",
|
||||
"hls.js": "1.7.2",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.3.0",
|
||||
"js-yaml": "5.4.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -118,7 +116,7 @@
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.10",
|
||||
"marked": "18.0.11",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -150,13 +148,13 @@
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@lokalise/node-api": "16.4.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/core": "2.2.2",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -174,13 +172,13 @@
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@versatiles/style": "5.13.1",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist": "4.28.9",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.9.0",
|
||||
"eslint": "10.10.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -190,9 +188,9 @@
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.11.0",
|
||||
"globals": "17.12.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -202,12 +200,12 @@
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.3.0",
|
||||
"lint-staged": "17.5.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"minify-literals": "2.2.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
@@ -217,9 +215,10 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "8.2.2",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.11",
|
||||
"vitest": "5.0.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
@@ -235,6 +234,6 @@
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"volta": {
|
||||
"node": "24.19.0"
|
||||
"node": "24.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260826.7"
|
||||
version = "20260826.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -62,12 +62,16 @@ export function computeCssColor(color: string): string {
|
||||
/**
|
||||
* Get a color from document's styles
|
||||
* @param color - Named theme color (examples: `red`, `primary-text`)
|
||||
* @returns Resolved color; initial color if not found in document's styles
|
||||
* @param style - Styles to resolve against, defaults to the document body
|
||||
* @returns Resolved color; initial color if not found in the styles
|
||||
*/
|
||||
export function resolveThemeColor(color: string): string {
|
||||
export function resolveThemeColor(
|
||||
color: string,
|
||||
style?: CSSStyleDeclaration
|
||||
): string {
|
||||
const cssColor = computeCssVariableName(color);
|
||||
if (cssColor.startsWith("--")) {
|
||||
const resolved = getComputedStyle(document.body)
|
||||
const resolved = (style ?? getComputedStyle(document.body))
|
||||
.getPropertyValue(cssColor)
|
||||
.trim();
|
||||
return resolved || color;
|
||||
|
||||
+22
-5
@@ -1,4 +1,4 @@
|
||||
import { wcagLuminance, wcagContrast } from "culori";
|
||||
import { parse, wcagLuminance, wcagContrast } from "culori";
|
||||
import { theme2hex } from "./convert-color";
|
||||
|
||||
/**
|
||||
@@ -51,11 +51,28 @@ export const getRGBContrastRatio = (
|
||||
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Returns a contrasted color (black or white) based on the luminance of another color
|
||||
* Tells whether a color can be measured, which a CSS function that is passed
|
||||
* through unevaluated cannot, and whether it covers what is behind it
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to check
|
||||
* @returns Whether a contrast against this color says anything
|
||||
*/
|
||||
export const isOpaqueColor = (color: string): boolean => {
|
||||
const parsed = parse(color.trim());
|
||||
return parsed !== undefined && (parsed.alpha ?? 1) === 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a contrasted color (black or white) for another color
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
|
||||
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
|
||||
* @returns HEX color, whichever of black and white has the higher contrast ratio
|
||||
*/
|
||||
export const getContrastedColorHex = (color: string): string => {
|
||||
const lum = wcagLuminance(theme2hex(color));
|
||||
return lum > 0.5 ? "#000000" : "#ffffff";
|
||||
const hex = theme2hex(color.trim());
|
||||
// culori throws on a color it cannot read
|
||||
if (!parse(hex)) {
|
||||
return "#ffffff";
|
||||
}
|
||||
return wcagContrast(hex, "#000000") >= wcagContrast(hex, "#ffffff")
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
import {
|
||||
isClientCondition,
|
||||
isDisabledCondition,
|
||||
isEntityReference,
|
||||
isLogicalCondition,
|
||||
logicalChildren,
|
||||
} from "./translate";
|
||||
|
||||
// Combinators: true / false / undefined (unknown)
|
||||
const andOf = (values: (boolean | undefined)[]): boolean | undefined => {
|
||||
let unknown = false;
|
||||
for (const value of values) {
|
||||
if (value === false) return false;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : true;
|
||||
};
|
||||
|
||||
const orOf = (values: (boolean | undefined)[]): boolean | undefined => {
|
||||
let unknown = false;
|
||||
for (const value of values) {
|
||||
if (value === true) return true;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when `checkConditionsMet` matches core for this leaf, so we can
|
||||
* show a result before `subscribe_condition` replies.
|
||||
*/
|
||||
const isLocallyEvaluableServerLeaf = (
|
||||
condition: VisibilityCondition,
|
||||
states: HomeAssistant["states"],
|
||||
context: ConditionContext
|
||||
): boolean => {
|
||||
const type = "condition" in condition ? condition.condition : "state";
|
||||
if (type !== "state" && type !== "numeric_state") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!("entity_id" in condition)) {
|
||||
// Empty `entity: ""` falls back to the host entity, like checkStateCondition.
|
||||
const target =
|
||||
("entity" in condition ? condition.entity : undefined) ||
|
||||
context.entity_id;
|
||||
return !!target && target in states;
|
||||
}
|
||||
|
||||
const core = condition as {
|
||||
entity_id?: unknown;
|
||||
attribute?: unknown;
|
||||
for?: unknown;
|
||||
match?: unknown;
|
||||
value_template?: unknown;
|
||||
state?: unknown;
|
||||
above?: unknown;
|
||||
below?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof core.entity_id !== "string" ||
|
||||
!(core.entity_id in states) ||
|
||||
core.attribute !== undefined ||
|
||||
core.for !== undefined ||
|
||||
core.match !== undefined ||
|
||||
core.value_template !== undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (type === "numeric_state") {
|
||||
const bounds = [core.above, core.below].filter((b) => b !== undefined);
|
||||
return bounds.length > 0 && bounds.every((b) => typeof b === "number");
|
||||
}
|
||||
return !ensureArray(core.state as string | string[] | undefined)?.some(
|
||||
isEntityReference
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluate the parts of a visibility tree the client can know exactly.
|
||||
* Unknown leaves stay unknown (`not: [template]` is not treated as visible).
|
||||
* Returns `undefined` when the result still depends on core.
|
||||
*/
|
||||
export const evaluateConditionsLocally = (
|
||||
conditions: VisibilityCondition[],
|
||||
hass: HomeAssistant,
|
||||
context: ConditionContext
|
||||
): boolean | undefined => {
|
||||
const evaluateLeaf = (condition: VisibilityCondition): boolean => {
|
||||
try {
|
||||
return checkConditionsMet([condition as Condition], hass, context);
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateNode = (
|
||||
condition: VisibilityCondition
|
||||
): boolean | undefined => {
|
||||
// Template `enabled` is only known to core; the local evaluator ignores it.
|
||||
if ("enabled" in condition && typeof condition.enabled !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
if (isLogicalCondition(condition)) {
|
||||
// Missing `conditions` is treated as true, matching checkAnd/Or/NotCondition.
|
||||
if (condition.conditions === undefined) {
|
||||
return true;
|
||||
}
|
||||
const values = logicalChildren(condition)
|
||||
.filter((child) => !isDisabledCondition(child))
|
||||
.map(evaluateNode);
|
||||
if (condition.condition === "or") {
|
||||
return orOf(values);
|
||||
}
|
||||
const all = andOf(values);
|
||||
// Lovelace `not` is NOT(AND of children).
|
||||
return condition.condition === "not"
|
||||
? all === undefined
|
||||
? undefined
|
||||
: !all
|
||||
: all;
|
||||
}
|
||||
if (
|
||||
isClientCondition(condition) ||
|
||||
isLocallyEvaluableServerLeaf(condition, hass.states, context)
|
||||
) {
|
||||
return evaluateLeaf(condition);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Top-level list is AND. Skip `enabled: false` nodes, as core does.
|
||||
return andOf(
|
||||
conditions.filter((c) => !isDisabledCondition(c)).map(evaluateNode)
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,25 @@
|
||||
import type {
|
||||
Condition,
|
||||
TimeCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
|
||||
/**
|
||||
* Extract media queries from conditions recursively
|
||||
*/
|
||||
export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
export function extractMediaQueries(
|
||||
conditions: VisibilityCondition[]
|
||||
): string[] {
|
||||
return conditions.reduce<string[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractMediaQueries(c.conditions));
|
||||
array.push(...extractMediaQueries(ensureArray(c.conditions)));
|
||||
}
|
||||
if (c.condition === "screen" && c.media_query) {
|
||||
if (
|
||||
"condition" in c &&
|
||||
c.condition === "screen" &&
|
||||
"media_query" in c &&
|
||||
c.media_query
|
||||
) {
|
||||
array.push(c.media_query);
|
||||
}
|
||||
return array;
|
||||
@@ -22,14 +30,15 @@ export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
* Extract time conditions from conditions recursively
|
||||
*/
|
||||
export function extractTimeConditions(
|
||||
conditions: Condition[]
|
||||
conditions: VisibilityCondition[]
|
||||
): TimeCondition[] {
|
||||
return conditions.reduce<TimeCondition[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractTimeConditions(c.conditions));
|
||||
array.push(...extractTimeConditions(ensureArray(c.conditions)));
|
||||
}
|
||||
if (c.condition === "time") {
|
||||
array.push(c);
|
||||
if ("condition" in c && c.condition === "time") {
|
||||
// Dashboard `time` is always the client lovelace shape.
|
||||
array.push(c as TimeCondition);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { listenMediaQuery } from "../dom/media_query";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
TimeCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import { extractMediaQueries, extractTimeConditions } from "./extract";
|
||||
import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
|
||||
@@ -16,95 +15,65 @@ import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
const MAX_TIMEOUT_DELAY = 2147483647;
|
||||
|
||||
/**
|
||||
* Helper to setup media query listeners for conditional visibility
|
||||
* Fire onChange at the next time-condition boundary, then reschedule.
|
||||
* Delays past the setTimeout max are capped and retried without firing.
|
||||
*/
|
||||
export function setupMediaQueryListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
function scheduleTimeBoundaryListener(
|
||||
getHass: () => HomeAssistant,
|
||||
timeCondition: Omit<TimeCondition, "condition">,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
onBoundary: () => void
|
||||
): void {
|
||||
const mediaQueries = extractMediaQueries(conditions);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (mediaQueries.length === 0) return;
|
||||
const scheduleUpdate = () => {
|
||||
// Read hass here so a timezone change applies at the next boundary.
|
||||
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
|
||||
|
||||
// Optimization for single media query
|
||||
const hasOnlyMediaQuery =
|
||||
conditions.length === 1 &&
|
||||
conditions[0].condition === "screen" &&
|
||||
!!conditions[0].media_query;
|
||||
if (delay === undefined) return;
|
||||
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
const unsub = listenMediaQuery(mediaQuery, (matches) => {
|
||||
if (hasOnlyMediaQuery) {
|
||||
onUpdate(matches);
|
||||
} else {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
onBoundary();
|
||||
}
|
||||
});
|
||||
addListener(unsub);
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to setup time-based listeners for conditional visibility
|
||||
* Observe the client-evaluated parts of a condition tree — `screen` media
|
||||
* queries and `time` boundaries — and invoke `onChange` whenever one of them
|
||||
* could have flipped.
|
||||
*
|
||||
* This does not evaluate the conditions itself: the caller recombines client
|
||||
* and server results on notification. Used by `ConditionEvaluatorController`,
|
||||
* which merges these client signals with the results of `subscribe_condition`
|
||||
* subscriptions.
|
||||
*/
|
||||
export function setupTimeListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
export function observeConditionChanges(
|
||||
conditions: VisibilityCondition[],
|
||||
getHass: () => HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
onChange: () => void
|
||||
): void {
|
||||
const timeConditions = extractTimeConditions(conditions);
|
||||
extractMediaQueries(conditions).forEach((mediaQuery) => {
|
||||
addListener(listenMediaQuery(mediaQuery, () => onChange()));
|
||||
});
|
||||
|
||||
if (timeConditions.length === 0) return;
|
||||
|
||||
timeConditions.forEach((timeCondition) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
const delay = calculateNextTimeUpdate(hass, timeCondition);
|
||||
|
||||
if (delay === undefined) return;
|
||||
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
extractTimeConditions(conditions).forEach((timeCondition) => {
|
||||
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, onChange);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up all condition listeners (media query, time) for conditional visibility.
|
||||
*/
|
||||
export function setupConditionListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { Condition as CoreCondition } from "../../data/automation";
|
||||
import type { VisibilityCondition } from "../../panels/lovelace/common/validate-condition";
|
||||
import {
|
||||
isDisabledCondition,
|
||||
isLogicalCondition,
|
||||
isServerCondition,
|
||||
logicalChildren,
|
||||
translateToCoreCondition,
|
||||
} from "./translate";
|
||||
|
||||
/** One `subscribe_condition` (largest server subtree we can group). */
|
||||
export interface ServerSubtree {
|
||||
id: string;
|
||||
coreCondition: CoreCondition;
|
||||
}
|
||||
|
||||
/** Evaluate a client-only leaf. `undefined` if it cannot be decided yet. */
|
||||
export type ClientConditionEvaluator = (
|
||||
condition: VisibilityCondition
|
||||
) => boolean | undefined;
|
||||
|
||||
/** Results by subtree id. `undefined` means not reported yet. */
|
||||
export type ServerConditionResults = Record<string, boolean | undefined>;
|
||||
|
||||
export interface SplitConditionTree {
|
||||
serverSubtrees: ServerSubtree[];
|
||||
/**
|
||||
* Combine client and server results. Returns `undefined` while a needed
|
||||
* server subtree has not reported.
|
||||
*/
|
||||
evaluate: (
|
||||
clientEvaluator: ClientConditionEvaluator,
|
||||
serverResults: ServerConditionResults
|
||||
) => boolean | undefined;
|
||||
}
|
||||
|
||||
type EvalNode = (
|
||||
clientEvaluator: ClientConditionEvaluator,
|
||||
serverResults: ServerConditionResults
|
||||
) => boolean | undefined;
|
||||
|
||||
// false wins AND, true wins OR, even if a sibling is still unknown.
|
||||
const andNode =
|
||||
(children: EvalNode[]): EvalNode =>
|
||||
(clientEvaluator, serverResults) => {
|
||||
let unknown = false;
|
||||
for (const child of children) {
|
||||
const value = child(clientEvaluator, serverResults);
|
||||
if (value === false) return false;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : true;
|
||||
};
|
||||
|
||||
const orNode =
|
||||
(children: EvalNode[]): EvalNode =>
|
||||
(clientEvaluator, serverResults) => {
|
||||
let unknown = false;
|
||||
for (const child of children) {
|
||||
const value = child(clientEvaluator, serverResults);
|
||||
if (value === true) return true;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : false;
|
||||
};
|
||||
|
||||
const notNode =
|
||||
(child: EvalNode): EvalNode =>
|
||||
(clientEvaluator, serverResults) => {
|
||||
const value = child(clientEvaluator, serverResults);
|
||||
return value === undefined ? undefined : !value;
|
||||
};
|
||||
|
||||
const serverLeaf =
|
||||
(id: string): EvalNode =>
|
||||
(_clientEvaluator, serverResults) =>
|
||||
serverResults[id];
|
||||
|
||||
const clientLeaf =
|
||||
(condition: VisibilityCondition): EvalNode =>
|
||||
(clientEvaluator) =>
|
||||
clientEvaluator(condition);
|
||||
|
||||
const unknownLeaf: EvalNode = () => undefined;
|
||||
|
||||
/**
|
||||
* Split a visibility tree into server subscriptions and a local combiner.
|
||||
*
|
||||
* Sibling server conditions under the same parent share one subscription.
|
||||
* `enabled: false` is skipped. A template `enabled` on a client node stays
|
||||
* unknown. Lovelace `not` is NOT(AND of children).
|
||||
*/
|
||||
export const splitConditionTree = (
|
||||
conditions: VisibilityCondition[]
|
||||
): SplitConditionTree => {
|
||||
const serverSubtrees: ServerSubtree[] = [];
|
||||
let nextId = 0;
|
||||
|
||||
const addSubtree = (coreCondition: CoreCondition): EvalNode => {
|
||||
const id = String(nextId);
|
||||
nextId += 1;
|
||||
serverSubtrees.push({ id, coreCondition });
|
||||
return serverLeaf(id);
|
||||
};
|
||||
|
||||
// Group server siblings into one subscription; recurse into client children.
|
||||
const buildSiblings = (
|
||||
children: VisibilityCondition[],
|
||||
groupOperator: "and" | "or"
|
||||
): EvalNode[] => {
|
||||
const serverChildren: VisibilityCondition[] = [];
|
||||
const clientChildren: VisibilityCondition[] = [];
|
||||
for (const child of children) {
|
||||
// Core skips disabled nodes; don't subscribe or combine them.
|
||||
if (isDisabledCondition(child)) {
|
||||
continue;
|
||||
}
|
||||
(isServerCondition(child) ? serverChildren : clientChildren).push(child);
|
||||
}
|
||||
|
||||
const nodes: EvalNode[] = [];
|
||||
|
||||
if (serverChildren.length === 1) {
|
||||
nodes.push(addSubtree(translateToCoreCondition(serverChildren[0])));
|
||||
} else if (serverChildren.length > 1) {
|
||||
nodes.push(
|
||||
addSubtree({
|
||||
condition: groupOperator,
|
||||
conditions: serverChildren.map(translateToCoreCondition),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const child of clientChildren) {
|
||||
nodes.push(build(child));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const build = (condition: VisibilityCondition): EvalNode => {
|
||||
// Template `enabled` on a client node can't be rendered here; stay unknown.
|
||||
if ("enabled" in condition && typeof condition.enabled !== "boolean") {
|
||||
return unknownLeaf;
|
||||
}
|
||||
if (isLogicalCondition(condition)) {
|
||||
const children = logicalChildren(condition);
|
||||
if (condition.condition === "or") {
|
||||
return orNode(buildSiblings(children, "or"));
|
||||
}
|
||||
if (condition.condition === "not") {
|
||||
return notNode(andNode(buildSiblings(children, "and")));
|
||||
}
|
||||
return andNode(buildSiblings(children, "and"));
|
||||
}
|
||||
// Should only happen if a server leaf slipped past grouping.
|
||||
if (isServerCondition(condition)) {
|
||||
return addSubtree(translateToCoreCondition(condition));
|
||||
}
|
||||
return clientLeaf(condition);
|
||||
};
|
||||
|
||||
const root = andNode(buildSiblings(conditions, "and"));
|
||||
|
||||
return {
|
||||
serverSubtrees,
|
||||
evaluate: (clientEvaluator, serverResults) =>
|
||||
root(clientEvaluator, serverResults),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,304 @@
|
||||
import type {
|
||||
Condition as CoreCondition,
|
||||
NumericStateCondition as CoreNumericStateCondition,
|
||||
StateCondition as CoreStateCondition,
|
||||
TemplateCondition as CoreTemplateCondition,
|
||||
} from "../../data/automation";
|
||||
import {
|
||||
CONDITION_ROW_CONFIG_KEYS,
|
||||
pickRowConfig,
|
||||
} from "../../data/automation";
|
||||
import type {
|
||||
LegacyCondition,
|
||||
NumericStateCondition as LovelaceNumericStateCondition,
|
||||
StateCondition as LovelaceStateCondition,
|
||||
VisibilityCondition,
|
||||
VisibilityLogicalCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
import { isValidEntityId } from "../entity/valid_entity_id";
|
||||
|
||||
/** Client-only types. Never sent to `subscribe_condition`. */
|
||||
const CLIENT_CONDITION_TYPES = new Set([
|
||||
"screen",
|
||||
"user",
|
||||
"view_columns",
|
||||
"location",
|
||||
"time",
|
||||
]);
|
||||
|
||||
const LOGICAL_CONDITION_TYPES = new Set(["and", "or", "not"]);
|
||||
|
||||
/** `and` / `or` / `not`. */
|
||||
export const isLogicalCondition = (
|
||||
condition: VisibilityCondition
|
||||
): condition is VisibilityLogicalCondition =>
|
||||
"condition" in condition && LOGICAL_CONDITION_TYPES.has(condition.condition);
|
||||
|
||||
/** Children of a logical condition. Core allows a single item or a list. */
|
||||
export const logicalChildren = (
|
||||
condition: VisibilityLogicalCondition
|
||||
): VisibilityCondition[] => ensureArray(condition.conditions) ?? [];
|
||||
|
||||
/** True if `value` looks like an entity id, not a numeric literal. */
|
||||
export const isEntityReference = (value: unknown): value is string =>
|
||||
typeof value === "string" && isNaN(Number(value)) && isValidEntityId(value);
|
||||
|
||||
/**
|
||||
* Lovelace `state` / `numeric_state` that core would evaluate differently,
|
||||
* so existing dashboards keep the client result until the user edits them:
|
||||
* stringified attributes, any-entity comparison values, entity-id bounds.
|
||||
*/
|
||||
const hasLegacyOnlySemantics = (
|
||||
condition:
|
||||
LovelaceStateCondition | LovelaceNumericStateCondition | LegacyCondition
|
||||
): boolean => {
|
||||
if ("condition" in condition && condition.condition === "numeric_state") {
|
||||
return [condition.above, condition.below].some(isEntityReference);
|
||||
}
|
||||
const state = condition as LovelaceStateCondition | LegacyCondition;
|
||||
if ("attribute" in state && state.attribute !== undefined) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
...(ensureArray(state.state) ?? []),
|
||||
...(ensureArray(state.state_not) ?? []),
|
||||
].some(isEntityReference);
|
||||
};
|
||||
|
||||
/**
|
||||
* True if this node should go to `subscribe_condition`.
|
||||
* A mixed `and`/`or`/`not` is client-side so it can wrap server subtrees.
|
||||
* Lovelace `state`/`numeric_state` with {@link hasLegacyOnlySemantics} stay local.
|
||||
*/
|
||||
export const isServerCondition = (condition: VisibilityCondition): boolean => {
|
||||
if (isLogicalCondition(condition)) {
|
||||
return logicalChildren(condition).every(isServerCondition);
|
||||
}
|
||||
// `{ entity, state }` with no `condition` key is a state condition.
|
||||
if (!("condition" in condition)) {
|
||||
return !hasLegacyOnlySemantics(condition);
|
||||
}
|
||||
if (CLIENT_CONDITION_TYPES.has(condition.condition)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(condition.condition === "state" ||
|
||||
condition.condition === "numeric_state") &&
|
||||
!("entity_id" in condition)
|
||||
) {
|
||||
return !hasLegacyOnlySemantics(
|
||||
condition as LovelaceStateCondition | LovelaceNumericStateCondition
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** `enabled: false`. Core skips these inside `and` / `or` / `not`. */
|
||||
export const isDisabledCondition = (condition: VisibilityCondition): boolean =>
|
||||
"enabled" in condition && condition.enabled === false;
|
||||
|
||||
/** Inverse of {@link isServerCondition}. True if any leaf is client-side. */
|
||||
export const isClientCondition = (condition: VisibilityCondition): boolean =>
|
||||
!isServerCondition(condition);
|
||||
|
||||
/** True if every leaf is client-only (no `subscribe_condition` needed). */
|
||||
export const isPureClientCondition = (
|
||||
condition: VisibilityCondition
|
||||
): boolean =>
|
||||
isLogicalCondition(condition)
|
||||
? logicalChildren(condition).every(isPureClientCondition)
|
||||
: isClientCondition(condition);
|
||||
|
||||
/**
|
||||
* Lovelace → core automation condition. Already-core types pass through.
|
||||
* Client-only conditions should not be passed in.
|
||||
*/
|
||||
export const translateToCoreCondition = (
|
||||
condition: VisibilityCondition
|
||||
): CoreCondition => {
|
||||
// `{ entity, state, state_not }` with no `condition` key.
|
||||
if (!("condition" in condition)) {
|
||||
return translateStateCondition({ condition: "state", ...condition });
|
||||
}
|
||||
|
||||
if (isLogicalCondition(condition)) {
|
||||
return translateLogicalCondition(condition);
|
||||
}
|
||||
|
||||
switch (condition.condition) {
|
||||
case "state":
|
||||
return translateStateCondition(condition as LovelaceStateCondition);
|
||||
case "numeric_state":
|
||||
return translateNumericStateCondition(
|
||||
condition as LovelaceNumericStateCondition
|
||||
);
|
||||
default:
|
||||
return condition as CoreCondition;
|
||||
}
|
||||
};
|
||||
|
||||
// Always-false: not(and of nothing). Used for incomplete configs so we don't
|
||||
// emit something core's schema would reject (that would fail a grouped subscription).
|
||||
const alwaysFalseCondition = (): CoreCondition => ({
|
||||
condition: "not",
|
||||
conditions: [{ condition: "and", conditions: [] }],
|
||||
});
|
||||
|
||||
const translateStateCondition = (
|
||||
condition: LovelaceStateCondition | CoreStateCondition | LegacyCondition
|
||||
): CoreCondition => {
|
||||
if ("entity_id" in condition) {
|
||||
return condition as CoreStateCondition;
|
||||
}
|
||||
|
||||
const lovelace = condition as LovelaceStateCondition;
|
||||
|
||||
const rowConfig = pickRowConfig(lovelace, CONDITION_ROW_CONFIG_KEYS);
|
||||
|
||||
// Missing entity or comparison value: checkConditionsMet is false; core
|
||||
// would reject the schema.
|
||||
if (
|
||||
!lovelace.entity ||
|
||||
(lovelace.state === undefined && lovelace.state_not === undefined)
|
||||
) {
|
||||
return { ...rowConfig, ...alwaysFalseCondition() };
|
||||
}
|
||||
const base = {
|
||||
condition: "state" as const,
|
||||
entity_id: lovelace.entity,
|
||||
...(lovelace.attribute !== undefined
|
||||
? { attribute: lovelace.attribute }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Prefer `state` when both are set, matching checkConditionsMet.
|
||||
if (lovelace.state !== undefined) {
|
||||
return {
|
||||
...rowConfig,
|
||||
...base,
|
||||
state: lovelace.state,
|
||||
} as CoreStateCondition;
|
||||
}
|
||||
|
||||
// Core has no `state_not`; wrap a positive `state` in `not`.
|
||||
return {
|
||||
...rowConfig,
|
||||
condition: "not",
|
||||
conditions: [{ ...base, state: lovelace.state_not } as CoreStateCondition],
|
||||
};
|
||||
};
|
||||
|
||||
const translateNumericStateCondition = (
|
||||
condition: LovelaceNumericStateCondition | CoreNumericStateCondition
|
||||
): CoreCondition => {
|
||||
if ("entity_id" in condition) {
|
||||
return condition as CoreNumericStateCondition;
|
||||
}
|
||||
const lovelace = condition as LovelaceNumericStateCondition;
|
||||
const rowConfig = pickRowConfig(lovelace, CONDITION_ROW_CONFIG_KEYS);
|
||||
|
||||
// Missing entity: checkConditionsMet is false; core would reject the schema.
|
||||
if (!lovelace.entity) {
|
||||
return { ...rowConfig, ...alwaysFalseCondition() };
|
||||
}
|
||||
|
||||
const above = translateNumericBound(lovelace.above, "above");
|
||||
const below = translateNumericBound(lovelace.below, "below");
|
||||
|
||||
if (typeof above === "symbol" || typeof below === "symbol") {
|
||||
// `above: +∞` / `below: -∞` can never pass.
|
||||
return { ...rowConfig, ...alwaysFalseCondition() };
|
||||
}
|
||||
|
||||
if (above === undefined && below === undefined) {
|
||||
// No usable bound. Lovelace only checks that the value is numeric; core
|
||||
// requires a bound, so express that as a template.
|
||||
return {
|
||||
...rowConfig,
|
||||
...numericValueCondition(lovelace.entity, lovelace.attribute),
|
||||
};
|
||||
}
|
||||
|
||||
const core: CoreNumericStateCondition = {
|
||||
...rowConfig,
|
||||
condition: "numeric_state",
|
||||
entity_id: lovelace.entity,
|
||||
};
|
||||
if (lovelace.attribute !== undefined) {
|
||||
core.attribute = lovelace.attribute;
|
||||
}
|
||||
if (above !== undefined) {
|
||||
core.above = above;
|
||||
}
|
||||
if (below !== undefined) {
|
||||
core.below = below;
|
||||
}
|
||||
return core;
|
||||
};
|
||||
|
||||
// Numeric-only check when lovelace ignored every bound.
|
||||
const numericValueCondition = (
|
||||
entityId: string,
|
||||
attribute?: string
|
||||
): CoreTemplateCondition => ({
|
||||
condition: "template",
|
||||
value_template:
|
||||
attribute === undefined
|
||||
? `{{ is_number(states(${JSON.stringify(entityId)})) }}`
|
||||
: `{{ is_number(state_attr(${JSON.stringify(entityId)}, ${JSON.stringify(attribute)})) }}`,
|
||||
});
|
||||
|
||||
// Bound that can never pass (see `translateNumericBound`).
|
||||
const NEVER_SATISFIED = Symbol("never-satisfied");
|
||||
|
||||
/**
|
||||
* Map a lovelace numeric bound onto core.
|
||||
* Numeric strings become numbers (try Number() first — `"10.5"` looks like an id).
|
||||
* Real entity ids pass through. Junk is dropped. ±Infinity: never-pass sides
|
||||
* become {@link NEVER_SATISFIED}; always-pass sides are dropped (not JSON-safe).
|
||||
*/
|
||||
const translateNumericBound = (
|
||||
bound: string | number | null | undefined,
|
||||
side: "above" | "below"
|
||||
): string | number | undefined | typeof NEVER_SATISFIED => {
|
||||
// YAML `null` is the same as a missing bound.
|
||||
if (bound == null) {
|
||||
return undefined;
|
||||
}
|
||||
const numeric = typeof bound === "number" ? bound : Number(bound);
|
||||
if (isNaN(numeric)) {
|
||||
return typeof bound === "string" && isValidEntityId(bound)
|
||||
? bound
|
||||
: undefined;
|
||||
}
|
||||
if (!isFinite(numeric)) {
|
||||
return (side === "above") === numeric > 0 ? NEVER_SATISFIED : undefined;
|
||||
}
|
||||
return numeric;
|
||||
};
|
||||
|
||||
const translateLogicalCondition = (
|
||||
condition: VisibilityLogicalCondition
|
||||
): CoreCondition => {
|
||||
const rowConfig = pickRowConfig(condition, CONDITION_ROW_CONFIG_KEYS);
|
||||
|
||||
// Missing `conditions` is treated as true.
|
||||
if (condition.conditions === undefined) {
|
||||
return { ...rowConfig, condition: "and", conditions: [] };
|
||||
}
|
||||
|
||||
const conditions = logicalChildren(condition).map(translateToCoreCondition);
|
||||
|
||||
if (condition.condition === "not") {
|
||||
// Lovelace `not` is NOT(AND); core `not` is NOT(OR). Keep the `and`
|
||||
// wrapper even for one child: a skipped disabled child must stay false.
|
||||
return {
|
||||
...rowConfig,
|
||||
condition: "not",
|
||||
conditions: [{ condition: "and", conditions }],
|
||||
};
|
||||
}
|
||||
|
||||
return { ...rowConfig, condition: condition.condition, conditions };
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { subscribeCondition } from "../../data/automation";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { observeConditionChanges } from "../condition/listeners";
|
||||
import { isPureClientCondition } from "../condition/translate";
|
||||
import type {
|
||||
ClientConditionEvaluator,
|
||||
ServerConditionResults,
|
||||
SplitConditionTree,
|
||||
} from "../condition/split";
|
||||
import { splitConditionTree } from "../condition/split";
|
||||
|
||||
/** `unknown` until a server subtree reports. */
|
||||
export type ConditionEvaluation = "visible" | "hidden" | "unknown";
|
||||
|
||||
export interface ConditionEvaluatorOptions {
|
||||
onResult: (result: ConditionEvaluation, error?: string) => void;
|
||||
/** Wait this long before (re)opening subscriptions after the tree changes. */
|
||||
resubscribeDelay?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_RESUBSCRIBE_DELAY = 50;
|
||||
|
||||
const firstDefined = (
|
||||
values: Record<string, string | undefined>
|
||||
): string | undefined => Object.values(values).find((value) => !!value);
|
||||
|
||||
/**
|
||||
* Live evaluation of a visibility tree: `subscribe_condition` for server
|
||||
* subtrees, local checks for screen/user/time/etc. Call {@link observe} when
|
||||
* inputs change. Result stays `unknown` until server replies so nothing flashes.
|
||||
*/
|
||||
export class ConditionEvaluatorController implements ReactiveController {
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private readonly _onResult: ConditionEvaluatorOptions["onResult"];
|
||||
|
||||
private readonly _resubscribeDelay: number;
|
||||
|
||||
private _conditions?: VisibilityCondition[];
|
||||
|
||||
private _hass?: HomeAssistant;
|
||||
|
||||
private _getContext?: () => ConditionContext;
|
||||
|
||||
private _connected = false;
|
||||
|
||||
// Value of the live tree vs the tree a pending resubscribe will switch to.
|
||||
// Compared by content so a new array each render does not churn subscriptions.
|
||||
// `undefined` is a real signature, so pending work uses `_hasPendingResubscribe`.
|
||||
private _subscribedSignature?: string;
|
||||
|
||||
private _pendingSignature?: string;
|
||||
|
||||
private _hasPendingResubscribe = false;
|
||||
|
||||
// Resubscribe if hass.connection is replaced even when the tree is unchanged.
|
||||
private _subscribedConnection?: Connection;
|
||||
|
||||
// Skip JSON.stringify when the same array is observed again.
|
||||
private _lastConditionsRef?: VisibilityCondition[];
|
||||
|
||||
private _lastSignature?: string;
|
||||
|
||||
private _split?: SplitConditionTree;
|
||||
|
||||
private _serverResults: ServerConditionResults = {};
|
||||
|
||||
private _subtreeErrors: Record<string, string | undefined> = {};
|
||||
|
||||
// Template errors from core that still produced a result. Shown in editors,
|
||||
// but they do not force hidden.
|
||||
private _subtreeTemplateErrors: Record<string, string | undefined> = {};
|
||||
|
||||
private _subscriptions: Promise<UnsubscribeFunc>[] = [];
|
||||
|
||||
private _listeners: (() => void)[] = [];
|
||||
|
||||
// Ignore callbacks from a torn-down generation.
|
||||
private _generation = 0;
|
||||
|
||||
private _resubscribeTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private _result: ConditionEvaluation = "unknown";
|
||||
|
||||
private _error?: string;
|
||||
|
||||
private _notifiedResult?: ConditionEvaluation;
|
||||
|
||||
private _notifiedError?: string;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
options: ConditionEvaluatorOptions
|
||||
) {
|
||||
this._host = host;
|
||||
this._onResult = options.onResult;
|
||||
this._resubscribeDelay =
|
||||
options.resubscribeDelay ?? DEFAULT_RESUBSCRIBE_DELAY;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public get result(): ConditionEvaluation {
|
||||
return this._result;
|
||||
}
|
||||
|
||||
public get error(): string | undefined {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update inputs. Resubscribes only when the tree (or connection) changes.
|
||||
*/
|
||||
public observe(
|
||||
conditions: VisibilityCondition[] | undefined,
|
||||
hass: HomeAssistant | undefined,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this._conditions = conditions;
|
||||
this._hass = hass;
|
||||
this._getContext = getContext;
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._connected = true;
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._connected = false;
|
||||
this._teardown();
|
||||
// Subscriptions are gone; don't keep showing a stale result.
|
||||
this._notifiedResult = undefined;
|
||||
this._notifiedError = undefined;
|
||||
this._setResult("unknown", undefined);
|
||||
}
|
||||
|
||||
private _signatureOf(
|
||||
conditions: VisibilityCondition[] | undefined
|
||||
): string | undefined {
|
||||
if (conditions === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (conditions === this._lastConditionsRef) {
|
||||
return this._lastSignature;
|
||||
}
|
||||
this._lastConditionsRef = conditions;
|
||||
// JSON turns ±Infinity into null; append them so `.inf` bound flips resubscribe.
|
||||
const nonFinite: string[] = [];
|
||||
const json = JSON.stringify(conditions, (_key, value) => {
|
||||
if (typeof value === "number" && !isFinite(value)) {
|
||||
nonFinite.push(String(value));
|
||||
}
|
||||
return value;
|
||||
});
|
||||
this._lastSignature = nonFinite.length
|
||||
? `${json}|${nonFinite.join(",")}`
|
||||
: json;
|
||||
return this._lastSignature;
|
||||
}
|
||||
|
||||
private _sync(): void {
|
||||
if (!this._connected) {
|
||||
return;
|
||||
}
|
||||
const signature = this._signatureOf(this._conditions);
|
||||
// Resubscribe when the tree content or hass.connection changed.
|
||||
const targetSignature = this._hasPendingResubscribe
|
||||
? this._pendingSignature
|
||||
: this._subscribedSignature;
|
||||
const connectionReplaced =
|
||||
this._subscribedConnection !== undefined &&
|
||||
this._hass !== undefined &&
|
||||
this._hass.connection !== this._subscribedConnection;
|
||||
if (signature !== targetSignature || connectionReplaced) {
|
||||
// Drop the old subscriptions immediately so their result can't leak through.
|
||||
this._teardown();
|
||||
this._hasPendingResubscribe = true;
|
||||
this._pendingSignature = signature;
|
||||
if (
|
||||
this._conditions === undefined ||
|
||||
this._conditions.every(isPureClientCondition)
|
||||
) {
|
||||
// All client-side; no server subscription to debounce.
|
||||
this._subscribe();
|
||||
return;
|
||||
}
|
||||
this._scheduleResubscribe();
|
||||
}
|
||||
// Keep client leaves live. Pending resubscribe has no split → `unknown`.
|
||||
this._recompute();
|
||||
}
|
||||
|
||||
private _scheduleResubscribe(): void {
|
||||
if (this._resubscribeTimeout !== undefined) {
|
||||
clearTimeout(this._resubscribeTimeout);
|
||||
}
|
||||
this._resubscribeTimeout = setTimeout(() => {
|
||||
this._resubscribeTimeout = undefined;
|
||||
this._subscribe();
|
||||
}, this._resubscribeDelay);
|
||||
}
|
||||
|
||||
private _subscribe(): void {
|
||||
this._teardown();
|
||||
|
||||
const conditions = this._conditions;
|
||||
const hass = this._hass;
|
||||
this._pendingSignature = undefined;
|
||||
this._hasPendingResubscribe = false;
|
||||
|
||||
if (!conditions || !hass) {
|
||||
// Don't mark subscribed yet; hass often arrives after connect.
|
||||
this._setResult("unknown", undefined);
|
||||
return;
|
||||
}
|
||||
this._subscribedSignature = this._signatureOf(conditions);
|
||||
|
||||
const split = splitConditionTree(conditions);
|
||||
this._split = split;
|
||||
|
||||
const generation = this._generation;
|
||||
const connection: Connection = hass.connection;
|
||||
this._subscribedConnection = connection;
|
||||
|
||||
for (const subtree of split.serverSubtrees) {
|
||||
this._serverResults[subtree.id] = undefined;
|
||||
const subscription = subscribeCondition(
|
||||
connection,
|
||||
(message) => {
|
||||
if (generation !== this._generation) {
|
||||
return;
|
||||
}
|
||||
if (message.error !== undefined) {
|
||||
this._serverResults[subtree.id] = false;
|
||||
this._subtreeErrors[subtree.id] =
|
||||
typeof message.error === "string"
|
||||
? message.error
|
||||
: message.error.message;
|
||||
} else {
|
||||
this._serverResults[subtree.id] = message.result;
|
||||
this._subtreeErrors[subtree.id] = undefined;
|
||||
}
|
||||
this._subtreeTemplateErrors[subtree.id] = message.template_errors
|
||||
?.length
|
||||
? message.template_errors.join("\n")
|
||||
: undefined;
|
||||
this._recompute();
|
||||
},
|
||||
subtree.coreCondition
|
||||
);
|
||||
subscription.catch((err: unknown) => {
|
||||
if (generation !== this._generation) {
|
||||
return;
|
||||
}
|
||||
this._serverResults[subtree.id] = false;
|
||||
this._subtreeErrors[subtree.id] =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
this._recompute();
|
||||
});
|
||||
this._subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
() => this._hass ?? hass,
|
||||
(unsub) => this._listeners.push(unsub),
|
||||
() => this._recompute()
|
||||
);
|
||||
|
||||
this._recompute();
|
||||
}
|
||||
|
||||
private _recompute(): void {
|
||||
if (!this._split || !this._hass) {
|
||||
this._setResult("unknown", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const hass = this._hass;
|
||||
const context = this._getContext?.() ?? {};
|
||||
const clientEvaluator: ClientConditionEvaluator = (condition) => {
|
||||
try {
|
||||
return checkConditionsMet([condition as Condition], hass, context);
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const error = firstDefined(this._subtreeErrors);
|
||||
// Don't treat a server error as `false` that a client `not` would invert.
|
||||
if (error !== undefined) {
|
||||
this._setResult("hidden", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const value = this._split.evaluate(clientEvaluator, this._serverResults);
|
||||
const result: ConditionEvaluation =
|
||||
value === undefined ? "unknown" : value ? "visible" : "hidden";
|
||||
|
||||
this._setResult(result, firstDefined(this._subtreeTemplateErrors));
|
||||
}
|
||||
|
||||
private _setResult(
|
||||
result: ConditionEvaluation,
|
||||
error: string | undefined
|
||||
): void {
|
||||
this._result = result;
|
||||
this._error = error;
|
||||
if (result === this._notifiedResult && error === this._notifiedError) {
|
||||
return;
|
||||
}
|
||||
this._notifiedResult = result;
|
||||
this._notifiedError = error;
|
||||
this._onResult(result, error);
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
private _teardown(): void {
|
||||
this._generation += 1;
|
||||
if (this._resubscribeTimeout !== undefined) {
|
||||
clearTimeout(this._resubscribeTimeout);
|
||||
this._resubscribeTimeout = undefined;
|
||||
}
|
||||
for (const subscription of this._subscriptions) {
|
||||
subscription.then((unsub) => unsub()).catch(() => undefined);
|
||||
}
|
||||
this._subscriptions = [];
|
||||
for (const unsub of this._listeners) {
|
||||
unsub();
|
||||
}
|
||||
this._listeners = [];
|
||||
this._split = undefined;
|
||||
this._serverResults = {};
|
||||
this._subtreeErrors = {};
|
||||
this._subtreeTemplateErrors = {};
|
||||
this._subscribedSignature = undefined;
|
||||
this._pendingSignature = undefined;
|
||||
this._hasPendingResubscribe = false;
|
||||
this._subscribedConnection = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { setupConditionListeners } from "../condition/listeners";
|
||||
|
||||
/**
|
||||
* Reactive controller that manages the media-query and time-based listeners
|
||||
* needed to keep a set of lovelace visibility conditions evaluated live.
|
||||
*
|
||||
* The host is responsible for the actual evaluation (e.g. computing visible /
|
||||
* hidden / invalid state); the controller only triggers it via the supplied
|
||||
* `onUpdate` callback when something the conditions depend on changes. Call
|
||||
* `setup()` whenever the conditions change; the controller clears previous
|
||||
* listeners and re-subscribes. Listeners are automatically released when the
|
||||
* host disconnects.
|
||||
*/
|
||||
export class ConditionListenersController implements ReactiveController {
|
||||
private _unsubs: (() => void)[] = [];
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
public setup(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
onUpdate: () => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this.clear();
|
||||
if (!conditions.length) {
|
||||
return;
|
||||
}
|
||||
setupConditionListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => this._unsubs.push(unsub),
|
||||
() => onUpdate(),
|
||||
getContext
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (const unsub of this._unsubs) {
|
||||
unsub();
|
||||
}
|
||||
this._unsubs = [];
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export const filterPanelStyles = css`
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
ha-expansion-panel::part(summary) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.content {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { HaSlider } from "../../components/ha-slider";
|
||||
import type { MediaPlayerEntity } from "../../data/media-player";
|
||||
import { formatMediaTime, getCurrentProgress } from "../../data/media-player";
|
||||
|
||||
const PENDING_SEEK_TIMEOUT_MS = 5000;
|
||||
const PENDING_SEEK_TOLERANCE_S = 2;
|
||||
const PROGRESS_INTERVAL_MS = 1000;
|
||||
|
||||
export interface MediaProgressControllerOptions {
|
||||
getStateObj: () => MediaPlayerEntity | undefined;
|
||||
getSlider: () => HaSlider | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a media progress slider: ticks it while the media plays, holds it
|
||||
* on the target after a seek until the player state catches up, and leaves
|
||||
* it alone while the user drags it. The controller owns the slider value;
|
||||
* the host must not bind `.value` and reads `progress` for position text.
|
||||
*/
|
||||
export class MediaProgressController implements ReactiveController {
|
||||
/** Current position in media seconds, pending seek included. */
|
||||
public progress?: number;
|
||||
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private _options: MediaProgressControllerOptions;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
private _pendingPosition?: number;
|
||||
|
||||
private _pendingSince = 0;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
options: MediaProgressControllerOptions
|
||||
) {
|
||||
this._host = host;
|
||||
this._options = options;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostUpdate(): void {
|
||||
this._computeProgress();
|
||||
}
|
||||
|
||||
public hostUpdated(): void {
|
||||
this._writeSlider();
|
||||
this._syncInterval();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._stopInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a seek so the displayed position moves to the target immediately
|
||||
* instead of jumping back until the player state reflects the seek.
|
||||
*/
|
||||
public seek(position: number): void {
|
||||
this._pendingPosition = position;
|
||||
this._pendingSince = Date.now();
|
||||
this._tick();
|
||||
}
|
||||
|
||||
private _tick(): void {
|
||||
this._computeProgress();
|
||||
this._writeSlider();
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
private _syncInterval(): void {
|
||||
const stateObj = this._options.getStateObj();
|
||||
if (
|
||||
stateObj?.state === "playing" &&
|
||||
stateObj.attributes.media_duration &&
|
||||
stateObj.attributes.media_position !== undefined
|
||||
) {
|
||||
if (!this._interval) {
|
||||
this._interval = window.setInterval(
|
||||
() => this._tick(),
|
||||
PROGRESS_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._stopInterval();
|
||||
}
|
||||
}
|
||||
|
||||
private _stopInterval(): void {
|
||||
if (this._interval) {
|
||||
clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeProgress(): void {
|
||||
const stateObj = this._options.getStateObj();
|
||||
if (
|
||||
!stateObj ||
|
||||
!stateObj.attributes.media_duration ||
|
||||
stateObj.attributes.media_position === undefined
|
||||
) {
|
||||
this.progress = undefined;
|
||||
return;
|
||||
}
|
||||
this.progress = this._applyPendingSeek(
|
||||
getCurrentProgress(stateObj),
|
||||
stateObj.state === "playing",
|
||||
stateObj.attributes.media_duration
|
||||
);
|
||||
}
|
||||
|
||||
private _applyPendingSeek(
|
||||
current: number,
|
||||
playing: boolean,
|
||||
duration: number
|
||||
): number {
|
||||
if (this._pendingPosition === undefined) {
|
||||
return current;
|
||||
}
|
||||
const elapsedMs = Date.now() - this._pendingSince;
|
||||
const target = Math.min(
|
||||
this._pendingPosition + (playing ? elapsedMs / 1000 : 0),
|
||||
duration
|
||||
);
|
||||
if (
|
||||
elapsedMs > PENDING_SEEK_TIMEOUT_MS ||
|
||||
Math.abs(current - target) <= PENDING_SEEK_TOLERANCE_S
|
||||
) {
|
||||
this._pendingPosition = undefined;
|
||||
return current;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private _writeSlider(): void {
|
||||
const slider = this._options.getSlider();
|
||||
if (!slider) {
|
||||
return;
|
||||
}
|
||||
slider.valueFormatter = formatMediaTime;
|
||||
if (slider.matches(":state(dragging)")) {
|
||||
return;
|
||||
}
|
||||
slider.value = this.progress ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { timerTimeRemaining } from "../../data/timer";
|
||||
|
||||
/**
|
||||
* Tracks the live remaining time of a timer entity. While the timer is
|
||||
* active, the host is re-rendered every second with an updated
|
||||
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
|
||||
*
|
||||
* The host must call `setStateObj` whenever its timer entity changes.
|
||||
*/
|
||||
export class TimerRemainingTimeController implements ReactiveController {
|
||||
public timeRemaining?: number;
|
||||
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public setStateObj(stateObj: HassEntity | undefined): void {
|
||||
this._stateObj = stateObj;
|
||||
this._startInterval();
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._startInterval();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._clearInterval();
|
||||
}
|
||||
|
||||
private _startInterval(): void {
|
||||
this._clearInterval();
|
||||
if (!this._stateObj) {
|
||||
this.timeRemaining = undefined;
|
||||
return;
|
||||
}
|
||||
this._calculateRemaining();
|
||||
|
||||
if (this._stateObj.state === "active") {
|
||||
this._interval = window.setInterval(() => {
|
||||
this._calculateRemaining();
|
||||
this._host.requestUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearInterval(): void {
|
||||
if (this._interval) {
|
||||
clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateRemaining(): void {
|
||||
this.timeRemaining = this._stateObj
|
||||
? timerTimeRemaining(this._stateObj)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,18 @@ export const formatDurationLong = (
|
||||
duration: HaDurationData
|
||||
) => formatDurationLongMem(locale).format(duration);
|
||||
|
||||
const formatDurationNarrowMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationNarrow = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDurationNarrowMem(locale).format(duration);
|
||||
|
||||
const formatDigitalDurationMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
|
||||
@@ -145,16 +145,11 @@ export const applyThemesOnElement = (
|
||||
element.__themes = { cacheKey, keys: newTheme?.keys };
|
||||
|
||||
// Set and/or reset styles
|
||||
if (window.ShadyCSS) {
|
||||
// Use ShadyCSS if available
|
||||
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
|
||||
} else {
|
||||
for (const s in styles) {
|
||||
if (s === null) {
|
||||
element.style.removeProperty(s);
|
||||
} else {
|
||||
element.style.setProperty(s, styles[s]);
|
||||
}
|
||||
for (const s in styles) {
|
||||
if (s === null) {
|
||||
element.style.removeProperty(s);
|
||||
} else {
|
||||
element.style.setProperty(s, styles[s]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Toggle Attribute Polyfill because it's too new for some browsers
|
||||
export const toggleAttribute = (
|
||||
el: HTMLElement,
|
||||
name: string,
|
||||
force?: boolean
|
||||
) => {
|
||||
if (force !== undefined) {
|
||||
force = !!force;
|
||||
}
|
||||
|
||||
if (el.hasAttribute(name)) {
|
||||
if (force) {
|
||||
return true;
|
||||
}
|
||||
|
||||
el.removeAttribute(name);
|
||||
return false;
|
||||
}
|
||||
if (force === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
el.setAttribute(name, "");
|
||||
return true;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const webComponentsSupported = "attachShadow" in Element.prototype;
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* ES5-compatible implementation of the keyed directive.
|
||||
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
|
||||
*
|
||||
* This implementation avoids parameter destructuring in the update() method,
|
||||
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
|
||||
*
|
||||
* Used only for ES5 builds (legacy browsers). Modern builds use the original
|
||||
* lit-html keyed directive.
|
||||
*
|
||||
* @see https://github.com/home-assistant/frontend/issues/28732
|
||||
*/
|
||||
import { directive, Directive } from "lit-html/directive.js";
|
||||
import { setCommittedValue } from "lit-html/directive-helpers.js";
|
||||
// eslint-disable-next-line lit/no-legacy-imports
|
||||
import { nothing } from "lit-html";
|
||||
import type { Part } from "lit-html/directive.js";
|
||||
|
||||
class KeyedES5 extends Directive {
|
||||
private _key: unknown = nothing;
|
||||
|
||||
render(k: unknown, v: unknown) {
|
||||
this._key = k;
|
||||
return v;
|
||||
}
|
||||
|
||||
update(part: unknown, args: [unknown, unknown]) {
|
||||
const k = args[0];
|
||||
const v = args[1];
|
||||
if (k !== this._key) {
|
||||
// Clear the part before returning a value. The one-arg form of
|
||||
// setCommittedValue sets the value to a sentinel which forces a
|
||||
// commit the next render.
|
||||
setCommittedValue(part as Part);
|
||||
this._key = k;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a renderable value with a unique key. When the key changes, the
|
||||
* previous DOM is removed and disposed before rendering the next value, even
|
||||
* if the value - such as a template - is the same.
|
||||
*
|
||||
* This is useful for forcing re-renders of stateful components, or working
|
||||
* with code that expects new data to generate new HTML elements, such as some
|
||||
* animation techniques.
|
||||
*/
|
||||
export const keyed = directive(KeyedES5);
|
||||
+191
-2
@@ -42,6 +42,7 @@ export const updateHistoryState = (patch: Record<string, unknown>) => {
|
||||
*/
|
||||
export const replaceCurrentUrl = (url: string) => {
|
||||
mainWindow.history.replaceState(mainWindow.history.state, "", url);
|
||||
rememberCurrentEntry();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,6 +81,105 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a page with unsaved changes (e.g. the automation editor) veto
|
||||
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
|
||||
* true when navigation may proceed.
|
||||
*/
|
||||
export interface UnsavedChangesGuard {
|
||||
isDirty(): boolean;
|
||||
prompt(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
|
||||
|
||||
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.add(guard);
|
||||
};
|
||||
|
||||
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.delete(guard);
|
||||
};
|
||||
|
||||
const dirtyGuards = (): UnsavedChangesGuard[] =>
|
||||
[...unsavedChangesGuards].filter((guard) => guard.isDirty());
|
||||
|
||||
let pendingUnsavedPrompt: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* Counts navigations that changed the history entry, so a navigation held up
|
||||
* by an unsaved-changes prompt can tell whether a newer one has moved the app
|
||||
* on in the meantime.
|
||||
*/
|
||||
let committedNavigations = 0;
|
||||
|
||||
interface HistoryEntry {
|
||||
path: string;
|
||||
/** Path of the entry behind this one, stamped by `performNavigation`. */
|
||||
from?: string;
|
||||
}
|
||||
|
||||
const readEntry = (): HistoryEntry => ({
|
||||
path: currentPath(),
|
||||
from: mainWindow.history.state?.from,
|
||||
});
|
||||
|
||||
/**
|
||||
* How far a pop moved from `entry`, signed, or undefined when it cannot be told:
|
||||
* the entry behind us is the one our `from` names, the one ahead is the one
|
||||
* whose `from` names us. A stack with the same path on both sides matches both,
|
||||
* and back is then by far the likelier press.
|
||||
*/
|
||||
const popStep = (entry: HistoryEntry): number | undefined => {
|
||||
if (currentPath() === entry.from) {
|
||||
return -1;
|
||||
}
|
||||
return mainWindow.history.state?.from === entry.path ? 1 : undefined;
|
||||
};
|
||||
|
||||
let currentEntry: HistoryEntry = readEntry();
|
||||
|
||||
const rememberCurrentEntry = () => {
|
||||
currentEntry = readEntry();
|
||||
};
|
||||
|
||||
/** Where the pops held while a prompt is open left us. */
|
||||
let heldEntry: HistoryEntry | undefined;
|
||||
let heldSteps = 0;
|
||||
|
||||
/** The entry `goBack()` asked to leave; the pop it triggers is not prompted. */
|
||||
let popRequestedFromPath: string | undefined;
|
||||
|
||||
/**
|
||||
* Asks each dirty guard whether navigation may proceed. Returns true when
|
||||
* nothing is dirty or every prompt was confirmed. Concurrent navigations
|
||||
* share one pending prompt instead of stacking dialogs; the dirty check runs
|
||||
* before joining it, so a navigation triggered from inside a prompt (e.g. by
|
||||
* its save action) cannot deadlock on its own promise.
|
||||
*/
|
||||
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
|
||||
const guards = dirtyGuards();
|
||||
if (!guards.length) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (!pendingUnsavedPrompt) {
|
||||
pendingUnsavedPrompt = (async () => {
|
||||
try {
|
||||
for (const guard of guards) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await guard.prompt())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
pendingUnsavedPrompt = undefined;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return pendingUnsavedPrompt;
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
@@ -91,7 +191,7 @@ const buildHistoryState = (
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
@@ -124,12 +224,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
);
|
||||
}
|
||||
|
||||
rememberCurrentEntry();
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
committedNavigations += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
// Only guard actual departures: navigating to the current path keeps the
|
||||
// page, and any unsaved state on it, mounted.
|
||||
if (path !== currentPath()) {
|
||||
const navigationsBeforePrompt = committedNavigations;
|
||||
if (!(await ensureUnsavedChangesConfirmed())) {
|
||||
return false;
|
||||
}
|
||||
if (committedNavigations !== navigationsBeforePrompt) {
|
||||
// Another navigation landed while the prompt was waiting for an answer,
|
||||
// so this destination is stale. Dropping it keeps a late answer from
|
||||
// pulling the user back off the page they are on now.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return performNavigation(path, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
@@ -142,6 +262,9 @@ export const canGoBack = (): boolean =>
|
||||
/**
|
||||
* Navigate back to the page we came from, falling back to a path when the
|
||||
* previous entry is not ours (deep link, login redirect, fresh tab).
|
||||
* Deliberately not guarded against unsaved changes: pages with such a guard
|
||||
* confirm in their own back handlers, and delete flows leave through here
|
||||
* after the edited item is already gone.
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -152,9 +275,75 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
// Read after closing dialogs: their history entries are popped by then, so
|
||||
// this is the state of the page entry.
|
||||
if (canGoBack()) {
|
||||
popRequestedFromPath = currentEntry.path;
|
||||
mainWindow.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
await performNavigation(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a history pop before the router acts on it. A pop cannot be cancelled,
|
||||
* so one away from a page with unsaved changes holds the route and prompts;
|
||||
* `resume` runs once the user agrees to leave. The prompt must add no history
|
||||
* entry of its own, or the entries the pop left would be truncated.
|
||||
*/
|
||||
export const handleHistoryPop = (resume: () => void): void => {
|
||||
if (heldEntry) {
|
||||
const heldStep = popStep(heldEntry);
|
||||
if (heldStep !== undefined) {
|
||||
heldSteps += heldStep;
|
||||
heldEntry = readEntry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPath() === currentEntry.path) {
|
||||
// A dialog's history entry was popped, not a page.
|
||||
rememberCurrentEntry();
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
|
||||
const step = popStep(currentEntry);
|
||||
if (
|
||||
popRequestedFromPath === currentEntry.path ||
|
||||
// Not a pop we could undo, so do not hold it.
|
||||
step === undefined ||
|
||||
!dirtyGuards().length
|
||||
) {
|
||||
popRequestedFromPath = undefined;
|
||||
rememberCurrentEntry();
|
||||
committedNavigations += 1;
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
|
||||
heldSteps = step;
|
||||
heldEntry = readEntry();
|
||||
const navigationsAtPop = committedNavigations;
|
||||
ensureUnsavedChangesConfirmed().then(
|
||||
(confirmed) => {
|
||||
const steps = heldSteps;
|
||||
heldEntry = undefined;
|
||||
if (committedNavigations !== navigationsAtPop) {
|
||||
// A navigation landed while the prompt was open.
|
||||
return;
|
||||
}
|
||||
if (!confirmed) {
|
||||
// go(0) would reload the document, and at zero we are already back.
|
||||
if (steps) {
|
||||
mainWindow.history.go(-steps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rememberCurrentEntry();
|
||||
committedNavigations += 1;
|
||||
resume();
|
||||
},
|
||||
() => {
|
||||
heldEntry = undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const isExternalHassUrl = (): boolean =>
|
||||
Boolean(__HASS_URL__) && __HASS_URL__ !== location.origin;
|
||||
|
||||
export const resolveHassUrl = (path: string): string =>
|
||||
isExternalHassUrl() ? new URL(path, __HASS_URL__).toString() : path;
|
||||
@@ -1,19 +0,0 @@
|
||||
export const startMediaProgressInterval = (
|
||||
interval: number | undefined,
|
||||
callback: () => void,
|
||||
intervalMs = 1000
|
||||
): number => {
|
||||
if (interval) {
|
||||
return interval;
|
||||
}
|
||||
return window.setInterval(callback, intervalMs);
|
||||
};
|
||||
|
||||
export const stopMediaProgressInterval = (
|
||||
interval: number | undefined
|
||||
): number | undefined => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import deepFreeze from "deep-freeze";
|
||||
|
||||
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
|
||||
|
||||
export const shareInFlightRequest = <T>(
|
||||
owner: object,
|
||||
key: string,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
let requests = inFlightRequests.get(owner);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
inFlightRequests.set(owner, requests);
|
||||
}
|
||||
|
||||
const ownerRequests = requests;
|
||||
const existing = ownerRequests.get(key);
|
||||
if (existing) {
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = fetcher()
|
||||
.then((result) => deepFreeze(result) as T)
|
||||
.finally(() => {
|
||||
if (ownerRequests.get(key) !== request) {
|
||||
return;
|
||||
}
|
||||
|
||||
ownerRequests.delete(key);
|
||||
if (ownerRequests.size === 0) {
|
||||
inFlightRequests.delete(owner);
|
||||
}
|
||||
});
|
||||
|
||||
ownerRequests.set(key, request);
|
||||
return request;
|
||||
};
|
||||
@@ -328,6 +328,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
onFractionDigits: (digits) => {
|
||||
if (digits !== this._yAxisFractionDigits) {
|
||||
this._yAxisFractionDigits = digits;
|
||||
|
||||
@@ -446,6 +446,7 @@ export class StatisticsChart extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
// Bar charts stay anchored at 0, so precision must reflect the
|
||||
// 0-based range that is actually rendered.
|
||||
includeZero: !yAxisScale,
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
|
||||
import { getPrecision, nice, round } from "echarts/lib/util/number";
|
||||
|
||||
// A range smaller than this fraction of the axis magnitude is floating-point
|
||||
// noise (e.g. from summed statistics), not real precision.
|
||||
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
|
||||
|
||||
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
|
||||
// precision derived here cannot drift from the ticks it renders.
|
||||
const SPLIT_NUMBER = 5;
|
||||
|
||||
// How thin a gap between the data and the plot edge counts as no gap at all,
|
||||
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
|
||||
// maximum to a tick multiple, which usually leaves headroom, but quantized
|
||||
// states often land exactly on a tick and get none — collapsing area-filled
|
||||
// series, which are drawn from their value down to the axis minimum. Widening
|
||||
// the extent by this much before that rounding bumps any axis with less
|
||||
// headroom out to a full tick, and leaves the rest where they are.
|
||||
const GAP_FRACTION_OF_SPAN = 0.02;
|
||||
|
||||
// A percentage has a real ceiling the way zero is a real floor, so the gap must
|
||||
// not push the axis past it. Not every `%` sensor is bounded — power factor is
|
||||
// signed and can read over 100 — so this only applies while the data stays under.
|
||||
const PERCENT_MAX = 100;
|
||||
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
// observed data range, by asking ECharts for the same tick interval it will
|
||||
// render. This matches the precision it actually draws, so labels are neither
|
||||
// truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -22,13 +41,7 @@ export function computeYAxisFractionDigits(
|
||||
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
|
||||
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
|
||||
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
// Rounding the mantissa to a nice value only ever carries to the next power
|
||||
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
|
||||
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
|
||||
return Math.max(0, -niceExponent);
|
||||
return getPrecision(nice(range / SPLIT_NUMBER, true));
|
||||
}
|
||||
|
||||
interface YAxisExtentValues {
|
||||
@@ -44,34 +57,116 @@ const resolveYAxisBound = (
|
||||
values: YAxisExtentValues
|
||||
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
|
||||
|
||||
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
|
||||
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
|
||||
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
|
||||
// always before the label formatter runs, so recomputing the fraction digits
|
||||
// here keeps zoomed-in labels distinct. The callbacks return the original
|
||||
// bounds unchanged, so auto-scaling still applies when a bound is not set.
|
||||
// A constant series has no span for the gap to be a fraction of, so ECharts
|
||||
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
|
||||
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
|
||||
// expansion a flat series relies on for its window. Run that expansion here and
|
||||
// return fixed bounds, which also suppresses the gap.
|
||||
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
|
||||
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
|
||||
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
|
||||
const precision = getPrecision(interval);
|
||||
return {
|
||||
min: round(Math.floor(lo / interval) * interval, precision),
|
||||
max: round(Math.ceil(hi / interval) * interval, precision),
|
||||
};
|
||||
};
|
||||
|
||||
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
|
||||
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
|
||||
// callbacks with the extent of the visible (zoom-filtered) data on every
|
||||
// dataZoom, and always before the label formatter runs, so the fraction digits
|
||||
// recomputed here track the zoomed range. A callback returns `undefined`
|
||||
// wherever auto-scaling should stand, and a number only where the axis has to be
|
||||
// pinned: an explicit bound, the zero anchor, or a constant series.
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
|
||||
// Such an axis also gets no gap: pushing it below zero would defeat the zero
|
||||
// anchoring and leave the bars floating above the axis.
|
||||
includeZero?: boolean;
|
||||
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
|
||||
// a limit the data itself never crosses.
|
||||
unit?: string;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
min: (values: YAxisExtentValues) => number | undefined;
|
||||
max: (values: YAxisExtentValues) => number | undefined;
|
||||
boundaryGap: [number, number];
|
||||
splitNumber: number;
|
||||
} {
|
||||
const { min, max, includeZero, onFractionDigits } = options;
|
||||
const { min, max, includeZero, unit, onFractionDigits } = options;
|
||||
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
|
||||
|
||||
const resolveBounds = (values: YAxisExtentValues) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
if (
|
||||
includeZero ||
|
||||
!Number.isFinite(values.min) ||
|
||||
!Number.isFinite(values.max)
|
||||
) {
|
||||
return { min: resolvedMin, max: resolvedMax, gap: 0 };
|
||||
}
|
||||
if (values.min === values.max) {
|
||||
const flat = flatSeriesExtent(values.min, [
|
||||
resolvedMin !== undefined,
|
||||
resolvedMax !== undefined,
|
||||
]);
|
||||
// The expansion is a fraction of the magnitude, so a constant series near
|
||||
// the ceiling would otherwise overshoot it too.
|
||||
const flatMax =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? Math.min(flat.max, naturalMax)
|
||||
: flat.max;
|
||||
return {
|
||||
min: resolvedMin ?? flat.min,
|
||||
max: resolvedMax ?? flatMax,
|
||||
gap: 0,
|
||||
};
|
||||
}
|
||||
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
|
||||
// Never let the gap carry a series past a boundary it does not itself cross.
|
||||
const floor = values.min >= 0 ? 0 : undefined;
|
||||
const ceiling =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? naturalMax
|
||||
: values.max <= 0
|
||||
? 0
|
||||
: undefined;
|
||||
return {
|
||||
min:
|
||||
resolvedMin ??
|
||||
(floor !== undefined && values.min - floor < gap ? floor : undefined),
|
||||
max:
|
||||
resolvedMax ??
|
||||
(ceiling !== undefined && ceiling - values.max < gap
|
||||
? ceiling
|
||||
: undefined),
|
||||
gap,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// Always emit the key. `setOption` merges the Y axis rather than replacing
|
||||
// it, so a conditionally spread gap would survive a chart switching to a
|
||||
// zero-anchored type and leave its bars floating.
|
||||
boundaryGap: includeZero
|
||||
? [0, 0]
|
||||
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
|
||||
splitNumber: SPLIT_NUMBER,
|
||||
min: (values) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
const bounds = resolveBounds(values);
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
computeYAxisFractionDigits(
|
||||
bounds.min ?? values.min - bounds.gap,
|
||||
bounds.max ?? values.max + bounds.gap,
|
||||
includeZero
|
||||
)
|
||||
);
|
||||
return resolvedMin;
|
||||
return bounds.min;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
max: (values) => resolveBounds(values).max,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,45 +204,17 @@ export class HaDataTable extends LitElement {
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public selectAll(): void {
|
||||
public selectAll(extraFilter?: (row: DataTableRowData) => boolean): void {
|
||||
this._checkedRows = (this._filteredData || [])
|
||||
.filter((data) => data.selectable !== false)
|
||||
.filter(
|
||||
(data) =>
|
||||
data.selectable !== false && (!extraFilter || extraFilter(data))
|
||||
)
|
||||
.map((data) => data[this.id]);
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public select(ids: string[], clear?: boolean): void {
|
||||
if (clear) {
|
||||
this._checkedRows = [];
|
||||
}
|
||||
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
|
||||
const rowLookup = new Map(
|
||||
(this._filteredData || []).map((data) => [data[this.id], data])
|
||||
);
|
||||
const checkedRows = new Set(this._checkedRows);
|
||||
ids.forEach((id) => {
|
||||
const row = rowLookup.get(id);
|
||||
if (row?.selectable !== false && !checkedRows.has(id)) {
|
||||
this._checkedRows.push(id);
|
||||
checkedRows.add(id);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public unselect(ids: string[]): void {
|
||||
ids.forEach((id) => {
|
||||
const index = this._checkedRows.indexOf(id);
|
||||
if (index > -1) {
|
||||
this._checkedRows.splice(index, 1);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._filteredData?.length) {
|
||||
@@ -1235,10 +1207,8 @@ export class HaDataTable extends LitElement {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.mdc-data-table__header-row {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mdc-data-table__cell,
|
||||
|
||||
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
getStatisticIds,
|
||||
getStatisticLabel,
|
||||
type StatisticsMetaData,
|
||||
} from "../../data/recorder";
|
||||
import { getStatisticIds } from "../../data/recorder_statistic_ids";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import "../ha-combo-box-item";
|
||||
|
||||
@@ -293,12 +293,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
|
||||
);
|
||||
overflow: hidden;
|
||||
color: var(--primary-text-color);
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-surface-backdrop-filter,
|
||||
none
|
||||
);
|
||||
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { fetchHassioAddonsInfo } from "../data/hassio/addon";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-app-icon";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
@@ -18,13 +19,22 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.repository", weight: 5 },
|
||||
];
|
||||
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
interface AddonPickerItem extends PickerComboBoxItem {
|
||||
slug: string;
|
||||
hasIcon: boolean;
|
||||
}
|
||||
|
||||
const rowRenderer: RenderItemFunction<AddonPickerItem> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
<span slot="headline">${item.primary}</span>
|
||||
<span slot="supporting-text">${item.secondary}</span>
|
||||
${
|
||||
item.icon
|
||||
? html` <img alt="" slot="start" .src=${item.icon} /> `
|
||||
item.hasIcon
|
||||
? html`<ha-app-icon
|
||||
slot="start"
|
||||
.slug=${item.slug}
|
||||
.hasIcon=${item.hasIcon}
|
||||
></ha-app-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
@@ -40,7 +50,7 @@ class HaAddonPicker extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@state() private _addons?: PickerComboBoxItem[];
|
||||
@state() private _addons?: AddonPickerItem[];
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@@ -102,11 +112,10 @@ class HaAddonPicker extends LitElement {
|
||||
.filter((addon) => addon.version)
|
||||
.map((addon) => ({
|
||||
id: addon.slug,
|
||||
slug: addon.slug,
|
||||
hasIcon: addon.icon,
|
||||
primary: addon.name,
|
||||
secondary: addon.slug,
|
||||
icon: addon.icon
|
||||
? `/api/hassio/addons/${addon.slug}/icon`
|
||||
: undefined,
|
||||
search_labels: {
|
||||
description: addon.description || null,
|
||||
repository: addon.repository || null,
|
||||
@@ -151,15 +160,22 @@ class HaAddonPicker extends LitElement {
|
||||
private _valueRenderer = (itemId: string) => {
|
||||
const item = this._addons!.find((addon) => addon.id === itemId);
|
||||
return html`${
|
||||
item?.icon
|
||||
? html`<img
|
||||
item?.hasIcon
|
||||
? html`<ha-app-icon
|
||||
slot="start"
|
||||
alt=${item.primary ?? "Unknown"}
|
||||
.src=${item.icon}
|
||||
/>`
|
||||
.slug=${item.slug}
|
||||
.hasIcon=${item.hasIcon}
|
||||
.alt=${item.primary ?? "Unknown"}
|
||||
></ha-app-icon>`
|
||||
: nothing
|
||||
}<span slot="headline">${item?.primary || "Unknown"}</span>`;
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
ha-app-icon {
|
||||
--ha-app-icon-size: var(--ha-space-8);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
|
||||
static styles = css`
|
||||
.issue-type {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
padding: var(--ha-alert-padding, 8px);
|
||||
display: flex;
|
||||
}
|
||||
.icon {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
|
||||
import { isExternalHassUrl } from "../common/url/hass-url";
|
||||
import { supervisorUrl } from "../data/hassio/common";
|
||||
|
||||
@customElement("ha-app-icon")
|
||||
export class HaAppIcon extends LitElement {
|
||||
@property() public slug = "";
|
||||
|
||||
@property({ attribute: "has-icon", type: Boolean })
|
||||
public hasIcon?: boolean;
|
||||
|
||||
@property() public alt = "";
|
||||
|
||||
@property() public loading: "eager" | "lazy" = "eager";
|
||||
|
||||
@state() private _failedSrc?: string;
|
||||
|
||||
@query("img") private _image?: HTMLImageElement;
|
||||
|
||||
protected render() {
|
||||
const src = supervisorUrl(`addons/${this.slug}/icon`);
|
||||
|
||||
if (!this.slug || this.hasIcon === false || this._failedSrc === src) {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
|
||||
return keyed(
|
||||
src,
|
||||
html`
|
||||
<img
|
||||
src=${src}
|
||||
alt=${this.alt}
|
||||
loading=${this.loading}
|
||||
crossorigin=${ifDefined(
|
||||
isExternalHassUrl() ? undefined : "anonymous"
|
||||
)}
|
||||
referrerpolicy="no-referrer"
|
||||
@error=${this._handleError}
|
||||
/>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
private _handleError(ev: HASSDomCurrentTargetEvent<HTMLImageElement>) {
|
||||
if (ev.currentTarget !== this._image) {
|
||||
return;
|
||||
}
|
||||
this._failedSrc = ev.currentTarget.getAttribute("src") ?? undefined;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--ha-app-icon-size, var(--mdc-icon-size));
|
||||
height: var(--ha-app-icon-size, var(--mdc-icon-size));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-app-icon": HaAppIcon;
|
||||
}
|
||||
}
|
||||
@@ -1061,31 +1061,18 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
-webkit-animation: sk-bounce 2s infinite ease-in-out;
|
||||
animation: sk-bounce 2s infinite ease-in-out;
|
||||
}
|
||||
.double-bounce2 {
|
||||
-webkit-animation-delay: -1s;
|
||||
animation-delay: -1s;
|
||||
}
|
||||
@-webkit-keyframes sk-bounce {
|
||||
0%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes sk-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ export class HaBadge extends LitElement {
|
||||
--ha-card-background,
|
||||
var(--card-background-color, white)
|
||||
);
|
||||
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
border-width: var(--ha-card-border-width, 1px);
|
||||
box-shadow: var(--ha-card-box-shadow, none);
|
||||
|
||||
@@ -432,13 +432,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
wa-drawer::part(dialog)::backdrop {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
@@ -470,10 +463,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
var(--card-background-color, var(--ha-color-surface-default))
|
||||
)
|
||||
);
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-bottom-sheet-surface-backdrop-filter,
|
||||
var(--ha-dialog-surface-backdrop-filter, none)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-surface-backdrop-filter,
|
||||
var(--ha-dialog-surface-backdrop-filter, none)
|
||||
|
||||
@@ -13,7 +13,6 @@ export class HaCard extends LitElement {
|
||||
--ha-card-background,
|
||||
var(--card-background-color, white)
|
||||
);
|
||||
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
box-shadow: var(--ha-card-box-shadow, none);
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -51,10 +51,19 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
white-space: normal;
|
||||
}
|
||||
::slotted(state-badge),
|
||||
::slotted(img) {
|
||||
::slotted(img),
|
||||
::slotted(ha-app-icon) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
::slotted(ha-app-icon.colored) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: var(--ha-space-1);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background-color: var(--app-icon-background-color);
|
||||
color: var(--white-color);
|
||||
}
|
||||
::slotted(.code) {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
|
||||
+6
-6
@@ -1,12 +1,12 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import "../../../components/list/ha-list-nav";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-icon-next";
|
||||
import "./ha-svg-icon";
|
||||
import "./item/ha-list-item-button";
|
||||
import "./list/ha-list-nav";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@customElement("ha-config-navigation-list")
|
||||
class HaConfigNavigationList extends LitElement {
|
||||
@@ -56,7 +56,7 @@ export class HaControlSelect extends LitElement {
|
||||
this.updateComplete.then(() => {
|
||||
// eslint-disable-next-line lit/prefer-query-decorators
|
||||
const option = this.shadowRoot?.querySelector(
|
||||
`#option-${this.options![index].value}`
|
||||
`[data-index="${index}"]`
|
||||
) as HTMLElement;
|
||||
option?.focus();
|
||||
});
|
||||
@@ -144,7 +144,11 @@ export class HaControlSelect extends LitElement {
|
||||
this.options,
|
||||
(option) => option.value,
|
||||
(option, index) =>
|
||||
this._renderOption(option, index === this._tabbableIndex)
|
||||
this._renderOption(
|
||||
option,
|
||||
index,
|
||||
index === this._tabbableIndex
|
||||
)
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
@@ -159,12 +163,17 @@ export class HaControlSelect extends LitElement {
|
||||
return selectedIndex === -1 ? 0 : selectedIndex;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
|
||||
private _renderOption(
|
||||
option: ControlSelectOption,
|
||||
index: number,
|
||||
tabbable: boolean
|
||||
) {
|
||||
const isSelected = this.value === option.value;
|
||||
|
||||
return html`
|
||||
<div
|
||||
id=${`option-${option.value}`}
|
||||
data-index=${index}
|
||||
class=${classMap({
|
||||
option: true,
|
||||
selected: isSelected,
|
||||
@@ -323,8 +332,6 @@ export class HaControlSelect extends LitElement {
|
||||
.option .content span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
-webkit-hyphens: auto;
|
||||
-moz-hyphens: auto;
|
||||
hyphens: auto;
|
||||
}
|
||||
:host([vertical]) {
|
||||
|
||||
@@ -393,10 +393,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-surface-backdrop-filter,
|
||||
none
|
||||
);
|
||||
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
|
||||
box-shadow: var(--dialog-box-shadow, var(--wa-shadow-l));
|
||||
color: var(--primary-text-color);
|
||||
@@ -429,10 +425,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog)::backdrop {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
|
||||
@@ -95,6 +95,10 @@ export class HaDrawer extends LitElement {
|
||||
}
|
||||
|
||||
private _handleAfterHide(ev: Event) {
|
||||
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this.open = false;
|
||||
fireEvent(this, "hass-drawer-closed");
|
||||
|
||||
@@ -63,11 +63,6 @@ class HaFaded extends LitElement {
|
||||
}
|
||||
.faded {
|
||||
cursor: pointer;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 25%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(to bottom, black 25%, transparent 100%);
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import "./ha-dialog-footer";
|
||||
import "./ha-filter-pane-chip";
|
||||
import "./ha-icon-button";
|
||||
|
||||
const HIGHLIGHT_DURATION = 1500;
|
||||
|
||||
/**
|
||||
* Filter pane for a filtered page: a column next to the content on wide
|
||||
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
|
||||
@@ -113,6 +115,15 @@ export class HaFilterPane extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public highlight() {
|
||||
const color = getComputedStyle(this).getPropertyValue("--primary-color");
|
||||
this.getAnimations().forEach((animation) => animation.cancel());
|
||||
this.animate([{ outlineColor: color }, { outlineColor: "transparent" }], {
|
||||
duration: HIGHLIGHT_DURATION,
|
||||
easing: "ease-out",
|
||||
});
|
||||
}
|
||||
|
||||
private _close() {
|
||||
fireEvent(this, "close-filter-pane");
|
||||
}
|
||||
@@ -133,6 +144,8 @@ export class HaFilterPane extends LitElement {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-inline-end: 1px solid var(--divider-color);
|
||||
outline: var(--ha-border-width-md) solid transparent;
|
||||
outline-offset: calc(-1 * var(--ha-border-width-md));
|
||||
}
|
||||
|
||||
/* The bottom sheet positions itself, so the pane takes no space. */
|
||||
|
||||
@@ -48,6 +48,7 @@ const SELECTOR_FALLBACK_VALUES = {
|
||||
selector: undefined,
|
||||
serial_port: undefined,
|
||||
state: undefined,
|
||||
state_class: undefined,
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: undefined,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
@@ -39,8 +40,8 @@ const SELECTOR_INITIAL_VALUES = {
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: (selector) => selector.country?.countries?.[0],
|
||||
date: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
|
||||
datetime: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
|
||||
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,
|
||||
@@ -91,6 +92,7 @@ const SELECTOR_INITIAL_VALUES = {
|
||||
selector: undefined,
|
||||
serial_port: () => "",
|
||||
state: (selector) => (selector.state?.multiple ? [] : ""),
|
||||
state_class: (selector) => (selector.state_class?.multiple ? [] : undefined),
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: () => ({}),
|
||||
@@ -98,7 +100,7 @@ const SELECTOR_INITIAL_VALUES = {
|
||||
text: (selector) => (selector.text?.multiple ? [] : ""),
|
||||
time: () => "00:00:00",
|
||||
icon: () => "",
|
||||
media: () => ({}),
|
||||
media: (selector) => (selector.media?.multiple ? [] : {}),
|
||||
theme: () => "",
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
@@ -106,7 +108,13 @@ const SELECTOR_INITIAL_VALUES = {
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => selector.color_temp?.min_mireds ?? 153,
|
||||
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,
|
||||
|
||||
@@ -57,7 +57,6 @@ export class HaFormString extends LitElement implements HaFormElement {
|
||||
.name=${this.schema.name}
|
||||
.autofocus=${!!this.schema.autofocus}
|
||||
.autocomplete=${this.schema.autocomplete}
|
||||
.inputId=${this.schema.name}
|
||||
.validationMessage=${
|
||||
this.schema.required
|
||||
? this.localize?.("ui.common.error_required")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiPlaylistPlus } from "@mdi/js";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext } from "../data/context";
|
||||
@@ -120,6 +122,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
|
||||
@query("wa-popover") private _popover?: WaPopover;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig?: ContextType<typeof configContext>;
|
||||
@@ -130,6 +134,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@state() private _popoverWidth = 0;
|
||||
|
||||
@state() private _popoverMinHeight = 0;
|
||||
|
||||
@state() private _openedNarrow = false;
|
||||
|
||||
@state() private _unknownValue = false;
|
||||
@@ -240,9 +246,12 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
: html`
|
||||
<wa-popover
|
||||
.open=${this._pickerWrapperOpen}
|
||||
style="--body-width: ${this._popoverWidth}px;"
|
||||
style=${styleMap({
|
||||
"--body-width": `${this._popoverWidth}px`,
|
||||
"--body-min-height": `${this._popoverMinHeight}px`,
|
||||
})}
|
||||
without-arrow
|
||||
distance="-4"
|
||||
distance="0"
|
||||
.placement=${this.popoverPlacement}
|
||||
.for=${this.popoverAnchor ? null : "picker"}
|
||||
.anchor=${this.popoverAnchor ?? null}
|
||||
@@ -265,12 +274,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderComboBox(dialogMode = false) {
|
||||
if (!this._opened) {
|
||||
// The list sizes the popover, so it is rendered as it opens, not once shown.
|
||||
if (!this._pickerWrapperOpen && !this._opened) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-picker-combo-box
|
||||
id="combo-box"
|
||||
.shown=${this._opened}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.label=${this.searchLabel}
|
||||
.value=${this._selectedValue ?? this.value}
|
||||
@@ -339,6 +350,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
private _dialogOpened = () => {
|
||||
this._opened = true;
|
||||
// Filtering must not shrink the popover under the size it opened with.
|
||||
this._popoverMinHeight = this._popover?.body.offsetHeight ?? 0;
|
||||
fireEvent(this, "picker-opened");
|
||||
requestAnimationFrame(() => {
|
||||
// Set initial field value if needed
|
||||
@@ -372,6 +385,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
this._opened = false;
|
||||
this._pickerWrapperOpen = false;
|
||||
this._popoverMinHeight = 0;
|
||||
this._selectedValue = undefined;
|
||||
this._unsubscribeTinyKeys?.();
|
||||
fireEvent(this, "picker-closed");
|
||||
@@ -474,6 +488,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
/* The surface of a dropdown menu, not of a dialog. */
|
||||
--wa-panel-border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
@@ -486,14 +502,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
--ha-generic-picker-max-width,
|
||||
var(--ha-generic-picker-width, max(var(--body-width), 250px))
|
||||
);
|
||||
max-height: 500px;
|
||||
height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--ha-box-shadow-m);
|
||||
height: fit-content;
|
||||
min-height: var(--body-min-height, 0);
|
||||
max-height: min(70vh, 500px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-height: 1000px) {
|
||||
wa-popover::part(body) {
|
||||
max-height: 400px;
|
||||
max-height: min(70vh, 400px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export class HaHeaderBar extends LitElement {
|
||||
--app-header-background-color,
|
||||
var(--primary-background-color)
|
||||
);
|
||||
backdrop-filter: var(--app-header-backdrop-filter, none);
|
||||
padding: var(--header-bar-padding);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ export class HaMarkdown extends LitElement {
|
||||
display: block;
|
||||
}
|
||||
ha-markdown-element {
|
||||
-ms-user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
ha-markdown-element > *:first-child {
|
||||
margin-top: 0;
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { mdiDelete, mdiPlayBox, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { MediaPickedEvent } from "../data/media-player";
|
||||
import {
|
||||
MediaClassBrowserSettings,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../data/selector";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-form/ha-form";
|
||||
import type { SchemaUnion } from "./ha-form/types";
|
||||
import "./media-player/ha-media-browser-thumbnail";
|
||||
import { showMediaBrowserDialog } from "./media-player/show-media-browser-dialog";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import "./ha-picture-upload";
|
||||
|
||||
const MANUAL_SCHEMA = [
|
||||
{ name: "media_content_id", required: false, selector: { text: {} } },
|
||||
{ name: "media_content_type", required: false, selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
const INCLUDE_DOMAINS = ["media_player"];
|
||||
|
||||
const EMPTY_FORM = {};
|
||||
|
||||
@customElement("ha-media-item-picker")
|
||||
export class HaMediaItemPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: MediaSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: MediaSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public required = true;
|
||||
|
||||
@property({ type: Boolean }) public multiple = false;
|
||||
|
||||
@property({ attribute: false }) public context?: {
|
||||
filter_entity?: string | string[];
|
||||
};
|
||||
|
||||
private _contextEntities: string[] | undefined;
|
||||
|
||||
private get _hasAccept(): boolean {
|
||||
return !!this.selector?.media?.accept?.length;
|
||||
}
|
||||
|
||||
willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("context")) {
|
||||
if (!this._hasAccept) {
|
||||
this._contextEntities = ensureArray(this.context?.filter_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const entityId = this._getActiveEntityId();
|
||||
|
||||
const stateObj = entityId ? this.hass.states[entityId] : undefined;
|
||||
|
||||
const supportsBrowse =
|
||||
!entityId ||
|
||||
(stateObj &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
|
||||
|
||||
if (this.selector.media?.image_upload && !this.value) {
|
||||
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${null}
|
||||
.contentIdHelper=${this.selector.media?.content_id_helper}
|
||||
select-media
|
||||
full-media
|
||||
@media-picked=${this._pictureUploadMediaPicked}
|
||||
></ha-picture-upload>`;
|
||||
}
|
||||
|
||||
const hideEntityPicker =
|
||||
this._hasAccept ||
|
||||
(this._contextEntities && this._contextEntities.length <= 1);
|
||||
return html`
|
||||
${
|
||||
hideEntityPicker
|
||||
? nothing
|
||||
: html`
|
||||
<ha-entity-picker
|
||||
.value=${entityId}
|
||||
.label=${
|
||||
this.label ||
|
||||
this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media_player"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.required=${this.required}
|
||||
.hideClearIcon=${!!this._contextEntities}
|
||||
.includeDomains=${INCLUDE_DOMAINS}
|
||||
.includeEntities=${this._contextEntities}
|
||||
.allowCustomEntity=${!this._contextEntities}
|
||||
@value-changed=${this._entityChanged}
|
||||
></ha-entity-picker>
|
||||
`
|
||||
}
|
||||
${
|
||||
!supportsBrowse
|
||||
? html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.components.selectors.media.browse_not_supported"
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.value || EMPTY_FORM}
|
||||
.schema=${MANUAL_SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
></ha-form>
|
||||
`
|
||||
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-card
|
||||
outlined
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title || this.value.media_content_id
|
||||
}
|
||||
@click=${this._pickMedia}
|
||||
@keydown=${this._handleKeyDown}
|
||||
class=${
|
||||
this.disabled || (!entityId && !this._hasAccept)
|
||||
? "disabled"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div class="content-container">
|
||||
<div class="thumbnail">
|
||||
${
|
||||
this.value?.metadata?.thumbnail
|
||||
? html`
|
||||
<div
|
||||
class="${classMap({
|
||||
"centered-image":
|
||||
!!this.value.metadata.media_class &&
|
||||
["app", "directory"].includes(
|
||||
this.value.metadata.media_class
|
||||
),
|
||||
})}
|
||||
image"
|
||||
>
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${this.value.metadata.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="icon-holder image">
|
||||
<ha-svg-icon
|
||||
class="folder"
|
||||
.path=${
|
||||
!this.value?.media_content_id
|
||||
? mdiPlus
|
||||
: this.value?.metadata?.media_class
|
||||
? MediaClassBrowserSettings[
|
||||
this.value.metadata.media_class ===
|
||||
"directory"
|
||||
? this.value.metadata
|
||||
.children_media_class ||
|
||||
this.value.metadata.media_class
|
||||
: this.value.metadata.media_class
|
||||
].icon
|
||||
: mdiPlayBox
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="title">
|
||||
${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title ||
|
||||
this.value.media_content_id
|
||||
}
|
||||
</div>
|
||||
${
|
||||
!this.multiple || !this.value
|
||||
? nothing
|
||||
: html`
|
||||
<div class="delete">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._clearValue}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
${
|
||||
this.selector.media?.image_upload
|
||||
? html`<div>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
variant="danger"
|
||||
@click=${this._clearValue}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.picture-upload.clear_picture"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
${
|
||||
hideEntityPicker && this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}`);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}_detail`);
|
||||
|
||||
private _entityChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!this._hasAccept && this.context?.filter_entity) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
metadata: {
|
||||
browse_entity_id: ev.detail.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
entity_id: ev.detail.value,
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _pickMedia() {
|
||||
showMediaBrowserDialog(this, {
|
||||
action: "pick",
|
||||
entityId: this._getActiveEntityId(),
|
||||
navigateIds: this.value?.metadata?.navigateIds,
|
||||
accept: this.selector.media?.accept,
|
||||
defaultId: this.value?.media_content_id,
|
||||
defaultType: this.value?.media_content_type,
|
||||
hideContentType: this.selector.media?.hide_content_type,
|
||||
contentIdHelper: this.selector.media?.content_id_helper,
|
||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
...(!this._hasAccept && this.context?.filter_entity
|
||||
? { browse_entity_id: this._getActiveEntityId() }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getActiveEntityId(): string | undefined {
|
||||
const metaId = this.value?.metadata?.browse_entity_id;
|
||||
return (
|
||||
this.value?.entity_id ||
|
||||
(metaId && this._contextEntities?.includes(metaId) && metaId) ||
|
||||
this._contextEntities?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._pickMedia();
|
||||
}
|
||||
}
|
||||
|
||||
private _pictureUploadMediaPicked(ev) {
|
||||
const pickedMedia = ev.detail as MediaPickedEvent;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _clearValue(ev) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: background-color 180ms ease-in-out;
|
||||
min-height: 56px;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-card:hover:not(.disabled),
|
||||
ha-card:focus:not(.disabled) {
|
||||
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
ha-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
ha-card.disabled {
|
||||
pointer-events: none;
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
.content-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
ha-card .thumbnail {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .image {
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
.folder {
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
--ha-media-browser-thumbnail-fit: cover;
|
||||
}
|
||||
.centered-image {
|
||||
margin: 4px;
|
||||
--ha-media-browser-thumbnail-fit: contain;
|
||||
}
|
||||
.icon-holder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-media-item-picker": HaMediaItemPicker;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user