mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 20:07:26 +00:00
Compare commits
290
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a87e7396c | ||
|
|
7e9dbcda14 | ||
|
|
7b6dba7777 | ||
|
|
7e2fd53b68 | ||
|
|
ecb36c6144 | ||
|
|
eea77277cd | ||
|
|
7494c2b917 | ||
|
|
c94227e830 | ||
|
|
d43178310b | ||
|
|
e8f7cc545e | ||
|
|
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 | ||
|
|
a406abfdcd | ||
|
|
fddc6e5506 | ||
|
|
69518334bb | ||
|
|
7a387004d6 | ||
|
|
1e9993785d | ||
|
|
1a3a93e3b9 | ||
|
|
c6dd741bb5 | ||
|
|
24f522b7ae | ||
|
|
0594229a45 | ||
|
|
615fd58324 | ||
|
|
fb35194041 | ||
|
|
d4e3ec858e | ||
|
|
6516b8de68 | ||
|
|
edb2f98d03 | ||
|
|
5ae95a1ea2 | ||
|
|
d0371e506f | ||
|
|
872205c352 | ||
|
|
bf939babfd | ||
|
|
d41b21b9d6 | ||
|
|
7e495f38d2 | ||
|
|
5cff0cb4c9 | ||
|
|
dbe2e2c079 | ||
|
|
e22b770252 | ||
|
|
fd388d99bf | ||
|
|
f9bfc66d5b | ||
|
|
c4ab017aed | ||
|
|
e22ef3e1f3 | ||
|
|
7a3764790b | ||
|
|
c499c2e030 | ||
|
|
13ec52ddbe | ||
|
|
d288cfd30a | ||
|
|
643e8934b4 | ||
|
|
9f3ca39738 | ||
|
|
8cee5155e6 | ||
|
|
15341a0aad | ||
|
|
9f8a2253ac | ||
|
|
97df5a5bf0 | ||
|
|
19cd06bb8c | ||
|
|
fcca87b368 | ||
|
|
2eb3b025d8 | ||
|
|
43628c5d69 | ||
|
|
0f52c00821 | ||
|
|
20f70cde7a | ||
|
|
b38d7bcb26 | ||
|
|
0572d71699 | ||
|
|
bcd13fcaf7 | ||
|
|
308eac97ee | ||
|
|
6da851219d | ||
|
|
970be43c3d | ||
|
|
8bb718a486 | ||
|
|
0e0bc389f8 | ||
|
|
70a482e0ed | ||
|
|
5a0db4a796 | ||
|
|
622f3e97e6 | ||
|
|
3091362040 | ||
|
|
3c7560a4e2 | ||
|
|
a9e178cb0e | ||
|
|
23b83fbd27 | ||
|
|
62849e2630 | ||
|
|
aa3e11f6e9 | ||
|
|
82ed13b7fc | ||
|
|
089592e7e3 | ||
|
|
4bc9b2be7b | ||
|
|
1778b7abff | ||
|
|
90567a491a | ||
|
|
409e2b315f | ||
|
|
eb1f8c1fab | ||
|
|
29cf30fc1a | ||
|
|
f88535c446 | ||
|
|
b9fd639bb7 | ||
|
|
2925dfc14e | ||
|
|
bb7ec34532 | ||
|
|
89015bc135 | ||
|
|
343f1e840a | ||
|
|
1369f80eb0 | ||
|
|
cd52f7b193 | ||
|
|
86dea86b6c | ||
|
|
04035016b0 | ||
|
|
52b0b46991 | ||
|
|
d5389b5bef | ||
|
|
f828c636a2 | ||
|
|
e91337b320 | ||
|
|
8ab7c118a9 | ||
|
|
0eafb1f4b4 | ||
|
|
9a7722a02e | ||
|
|
3e184eeb39 | ||
|
|
61be4e2952 | ||
|
|
315cf97e56 | ||
|
|
771a8aa4c9 | ||
|
|
d9aa649854 | ||
|
|
7a087cd6d0 | ||
|
|
32a30468bb | ||
|
|
0ace3cedfb | ||
|
|
aba7478e26 | ||
|
|
c8b2e5160a | ||
|
|
4c46ca0918 | ||
|
|
0483106f61 | ||
|
|
7e0c572f69 | ||
|
|
e4438a20cb | ||
|
|
ec418498a7 | ||
|
|
8122baca00 | ||
|
|
79738dd257 | ||
|
|
f23c8f0e23 | ||
|
|
91c28c2f58 | ||
|
|
1f299d98e0 | ||
|
|
a74926aef6 | ||
|
|
437a4cedf9 | ||
|
|
55913d7505 | ||
|
|
0bb654c70c | ||
|
|
f578cb0526 | ||
|
|
78878897cc | ||
|
|
9472d8ad12 | ||
|
|
94cbe4467b | ||
|
|
043abb69af | ||
|
|
b6b8fe641f | ||
|
|
70bb08b101 | ||
|
|
4259ddee24 | ||
|
|
6c5ac1ea84 | ||
|
|
5224fa2048 | ||
|
|
235541748b | ||
|
|
6bd1d698ce | ||
|
|
18862b868f | ||
|
|
662d817a5e | ||
|
|
7412ab5578 | ||
|
|
b2be0bd673 | ||
|
|
a141432546 | ||
|
|
cd50531b67 | ||
|
|
f7438f4d0e | ||
|
|
a7fabb8a8c | ||
|
|
9dab55f39a | ||
|
|
7ac98b7fdd | ||
|
|
5c1c92e8ca | ||
|
|
bf9765d557 | ||
|
|
b013b1f929 | ||
|
|
8026a59e70 | ||
|
|
ffa7cf17a6 | ||
|
|
5a5cec9060 | ||
|
|
bb962ba3d8 | ||
|
|
d4a59ff1c7 | ||
|
|
f496d842a6 | ||
|
|
c14d07c845 | ||
|
|
8a1f2e86bd | ||
|
|
1454f4d081 | ||
|
|
1a47c326b2 | ||
|
|
d8e827e08b | ||
|
|
301c907fa9 | ||
|
|
1ef3bc94d0 | ||
|
|
ba0367be2f | ||
|
|
c186a31056 | ||
|
|
bf8c92c95e | ||
|
|
9743117abf | ||
|
|
5e012973b2 | ||
|
|
ea659f1b33 | ||
|
|
539803cb5b | ||
|
|
944d3332d1 | ||
|
|
4a267d160f | ||
|
|
7edb9f8164 | ||
|
|
ff814167c6 | ||
|
|
d08d8dde64 | ||
|
|
5da441ceed | ||
|
|
22e646a68a | ||
|
|
d0ab20479f |
@@ -22,7 +22,7 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
|
||||
|
||||
- `yarn lint` passes when practical for the scope.
|
||||
- `yarn test` or focused relevant tests are green when practical for the scope.
|
||||
- Tests are added or updated for new data processing and utilities where applicable.
|
||||
- Each test added by the change protects real logic, not the look of a component.
|
||||
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
|
||||
- Components handle loading, error, unavailable, and missing-entity states.
|
||||
- Entity existence is checked before property access.
|
||||
|
||||
@@ -49,12 +49,13 @@ Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`;
|
||||
|
||||
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
|
||||
|
||||
## Unit And Utility Tests
|
||||
## When To Add Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
- Mock WebSocket connections and API calls at boundaries.
|
||||
- Cover loading, error, unavailable, and missing-entity states where relevant.
|
||||
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
|
||||
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
|
||||
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
|
||||
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
|
||||
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
|
||||
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
|
||||
+40
-24
@@ -1,36 +1,52 @@
|
||||
[modern]
|
||||
# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc.
|
||||
# It is served to browsers meeting the following requirements:
|
||||
# - released in the last year + current alpha/beta versions
|
||||
# - Firefox extended support release (ESR)
|
||||
# - with global utilization at or above 0.5%
|
||||
# - released in the last 2 years + current alpha/beta versions
|
||||
# - exclude dead browsers (no security maintenance for 2+ years)
|
||||
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
|
||||
# - exclude QQ, and UC browsers due to lack of sufficient feature support data
|
||||
unreleased versions
|
||||
last 1 year
|
||||
Firefox ESR
|
||||
>= 0.5%
|
||||
last 2 years
|
||||
not dead
|
||||
not KaiOS > 0
|
||||
not QQAndroid > 0
|
||||
not UCAndroid > 0
|
||||
|
||||
[legacy]
|
||||
# Legacy builds are served when modern requirements are not met and support browsers:
|
||||
# - released in the last 7 years + current alpha/beta versionss
|
||||
# - with global utilization at or above 0.05%
|
||||
# - exclude dead browsers (no security maintenance for 2+ years)
|
||||
# - exclude Opera Mini which does not support web sockets
|
||||
unreleased versions
|
||||
last 7 years
|
||||
>= 0.05%
|
||||
not dead
|
||||
not op_mini all
|
||||
# Legacy builds are served when modern requirements are not met.
|
||||
# Floors are pinned explicitly (not usage-based) so the support policy is
|
||||
# deliberate and does not drift with global usage statistics, which do not
|
||||
# represent old tablets and wall displays used as Home Assistant dashboards:
|
||||
# - iOS/Safari >= 12: iPad Air 1 / mini 2 / mini 3 (last supported iOS).
|
||||
# Older iPads (iPad 2/3/mini 1 on iOS 9.3, iPad 4 on iOS 10.3) cannot run
|
||||
# the app: their engines lack custom elements, shadow DOM, and/or CSS grid.
|
||||
# - Chrome >= 59: Fire OS 5 tablets (Fire 7/HD 8/HD 10 through ~2017) have
|
||||
# their system WebView pinned at Chromium 59 and commonly run Fully Kiosk;
|
||||
# also covers old kiosk browsers and no-longer-updating webviews above it.
|
||||
# - Edge >= 79: all Chromium-based Edge. Costs nothing (above the Chrome
|
||||
# floor); mainly catches Edge 109 on Windows 7/8.1 and update-frozen
|
||||
# enterprise installs, whose engines can run the legacy build fine.
|
||||
# - Firefox >= 94: the zero-cost floor, not a chased population — pinning it
|
||||
# adds no babel transforms, core-js modules, or Lightning CSS prefixes to
|
||||
# the output. Mozilla-supported Firefox (incl. current ESR) always matches
|
||||
# [modern]; Firefox on old OSes is not supported (use Chrome 109 instead).
|
||||
# If Firefox ever becomes the pin forcing extra output, raise it first.
|
||||
# - Samsung >= 9: Samsung Internet on old Galaxy tablets
|
||||
Chrome >= 59
|
||||
ChromeAndroid >= 59
|
||||
Edge >= 79
|
||||
Firefox >= 94
|
||||
FirefoxAndroid >= 94
|
||||
iOS >= 12
|
||||
Safari >= 12
|
||||
Samsung >= 9
|
||||
|
||||
[legacy-sw]
|
||||
# Same as legacy plus supports service workers
|
||||
unreleased versions
|
||||
last 7 years
|
||||
>= 0.05% and supports serviceworkers
|
||||
not dead
|
||||
not op_mini all
|
||||
# Same as legacy, restricted to browsers that support service workers
|
||||
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
|
||||
Chrome >= 59 and supports serviceworkers
|
||||
ChromeAndroid >= 59 and supports serviceworkers
|
||||
Edge >= 79 and supports serviceworkers
|
||||
Firefox >= 94 and supports serviceworkers
|
||||
FirefoxAndroid >= 94 and supports serviceworkers
|
||||
iOS >= 12 and supports serviceworkers
|
||||
Safari >= 12 and supports serviceworkers
|
||||
Samsung >= 9 and supports serviceworkers
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Sync device class constants
|
||||
|
||||
# Mirrors the device class constants for Home Assistant Core's into the
|
||||
# build-time default in src/data/devce_classes.ts and opens a PR
|
||||
# when it drifts. Reads homeassistant/generated/device_classes.json from core.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # Daily, 04:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate device class constants
|
||||
run: ./script/gen_device_classes
|
||||
|
||||
- name: Format
|
||||
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
branch: chore/sync-numeric-device-classes
|
||||
commit-message: Update numeric sensor device classes
|
||||
title: Update numeric sensor device classes
|
||||
body: |
|
||||
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
|
||||
`SensorDeviceClass`.
|
||||
|
||||
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
|
||||
+6
-5
@@ -1,7 +1,8 @@
|
||||
name: Sync numeric device classes
|
||||
name: Sync sensor entity constants
|
||||
|
||||
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
|
||||
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
|
||||
# Mirrors Home Assistant Core's numeric `SensorDeviceClass`, `SensorStateClass`,
|
||||
# units and related device and state classes arrays into the
|
||||
# build-time default in src/data/sensor_entity_constants.ts and opens a PR
|
||||
# when it drifts. Reads homeassistant/generated/sensor.json from core.
|
||||
|
||||
on:
|
||||
@@ -28,8 +29,8 @@ jobs:
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
- name: Regenerate sensor entity constants
|
||||
run: ./script/gen_sensor_entity_constants
|
||||
|
||||
- name: Format
|
||||
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
|
||||
@@ -74,3 +74,4 @@ test/e2e/app/dist/
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,6 +33,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
|
||||
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
|
||||
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
|
||||
- All user-facing text must be localized through the translation system.
|
||||
- Do not write tests just because you changed some code. Write a test when there is real logic that could break without anyone noticing, and explain what the test protects.
|
||||
|
||||
## Project Skills
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
"app": 576583,
|
||||
"core": 54790,
|
||||
"authorize": 543547,
|
||||
"onboarding": 655556
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
"app": 717124,
|
||||
"core": 181583,
|
||||
"authorize": 699175,
|
||||
"onboarding": 816133
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +63,10 @@ module.exports.htmlMinifierOptions = {
|
||||
};
|
||||
|
||||
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
|
||||
safari10: !latestBuild,
|
||||
ecma: latestBuild ? 2015 : 5,
|
||||
// Highest syntax the minifier may emit; it never downlevels. Every browser
|
||||
// in [modern] is well past ES2020 (universal since spring 2020); the
|
||||
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
|
||||
ecma: latestBuild ? 2020 : 2017,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
sourceMap: !isTestBuild,
|
||||
@@ -119,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
|
||||
@@ -153,8 +155,6 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
"proxy-polyfill",
|
||||
"unfetch",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
|
||||
16: [11, 0, 0],
|
||||
17: [12, 0, 0],
|
||||
18: [13, 0, 0],
|
||||
26: [26, 0, 0],
|
||||
26: [14, 6, 0],
|
||||
};
|
||||
|
||||
const getCommonTemplateVars = () => {
|
||||
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
|
||||
...htmlMinifierOptions,
|
||||
conservativeCollapse: false,
|
||||
minifyJS: terserOptions({
|
||||
latestBuild: false, // Shared scripts should be ES5
|
||||
latestBuild: false, // Shared scripts must satisfy the legacy targets
|
||||
isTestBuild: true, // Don't need source maps
|
||||
}),
|
||||
}).then((wrapped) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
@@ -41,37 +42,6 @@ function copyMdiIcons(staticDir) {
|
||||
fs.copySync(polyPath("build/mdi"), staticPath("mdi"));
|
||||
}
|
||||
|
||||
function copyPolyfills(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
|
||||
// For custom panels using ES5 builds that don't use Babel 7+
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
|
||||
// Web Component polyfills and adapters
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js.map"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
// Lit polyfill support
|
||||
fs.copySync(
|
||||
npmPath("lit/polyfill-support.js"),
|
||||
path.join(staticPath("polyfills/"), "lit-polyfill-support.js")
|
||||
);
|
||||
|
||||
// dialog-polyfill css
|
||||
copyFileDir(
|
||||
npmPath("dialog-polyfill/dialog-polyfill.css"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
}
|
||||
|
||||
function copyFonts(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Local fonts
|
||||
@@ -89,7 +59,7 @@ function copyQrScannerWorker(staticDir) {
|
||||
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
|
||||
}
|
||||
|
||||
function copyMapPanel(staticDir) {
|
||||
async function copyMapPanel(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
copyFileDir(
|
||||
npmPath("leaflet/dist/leaflet.css"),
|
||||
@@ -103,6 +73,16 @@ function copyMapPanel(staticDir) {
|
||||
npmPath("leaflet/dist/images"),
|
||||
staticPath("images/leaflet/images/")
|
||||
);
|
||||
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
copyFileDir(
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
// Controls and popups of the native MapLibre engine
|
||||
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
@@ -132,14 +112,13 @@ gulp.task("copy-static-app", async () => {
|
||||
const staticDir = paths.app_output_static;
|
||||
// Basic static files
|
||||
fs.copySync(polyPath("public"), paths.app_output_root);
|
||||
copyPolyfills(staticDir);
|
||||
copyFonts(staticDir);
|
||||
copyTranslations(staticDir);
|
||||
copyLocaleData(staticDir);
|
||||
copyMdiIcons(staticDir);
|
||||
|
||||
// Panel assets
|
||||
copyMapPanel(staticDir);
|
||||
await copyMapPanel(staticDir);
|
||||
|
||||
// Qr Scanner assets
|
||||
copyZXingWasm(staticDir);
|
||||
@@ -154,8 +133,7 @@ gulp.task("copy-static-demo", async () => {
|
||||
);
|
||||
// Copy demo static files
|
||||
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
|
||||
copyPolyfills(paths.demo_output_static);
|
||||
copyMapPanel(paths.demo_output_static);
|
||||
await copyMapPanel(paths.demo_output_static);
|
||||
copyFonts(paths.demo_output_static);
|
||||
copyTranslations(paths.demo_output_static);
|
||||
copyLocaleData(paths.demo_output_static);
|
||||
@@ -167,8 +145,7 @@ gulp.task("copy-static-cast", async () => {
|
||||
fs.copySync(polyPath("public/static"), paths.cast_output_static);
|
||||
// Copy cast static files
|
||||
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
|
||||
copyPolyfills(paths.cast_output_static);
|
||||
copyMapPanel(paths.cast_output_static);
|
||||
await copyMapPanel(paths.cast_output_static);
|
||||
copyFonts(paths.cast_output_static);
|
||||
copyTranslations(paths.cast_output_static);
|
||||
copyLocaleData(paths.cast_output_static);
|
||||
@@ -184,7 +161,7 @@ gulp.task("copy-static-gallery", async () => {
|
||||
paths.gallery_output_root
|
||||
);
|
||||
|
||||
copyMapPanel(paths.gallery_output_static);
|
||||
await copyMapPanel(paths.gallery_output_static);
|
||||
copyFonts(paths.gallery_output_static);
|
||||
copyTranslations(paths.gallery_output_static);
|
||||
copyLocaleData(paths.gallery_output_static);
|
||||
@@ -214,8 +191,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
await copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const SOURCE_URL =
|
||||
process.env.SENSOR_METADATA_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/generated/device_classes.json";
|
||||
|
||||
const TARGET = join(paths.root_dir, "src", "data", "device_classes.ts");
|
||||
|
||||
gulp.task("gen-device-classes", async () => {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const domainDeviceClasses = data ?? {};
|
||||
if (!Object.keys(domainDeviceClasses).length) {
|
||||
throw new Error(`No device classes found in ${SOURCE_URL}`);
|
||||
}
|
||||
|
||||
const content = `// This file is auto-generated from Home Assistant Core's
|
||||
// entity platform device classes. Do not edit by hand.
|
||||
// Regenerate with \`script/gen_device_classes\`.
|
||||
|
||||
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
|
||||
${Object.entries(domainDeviceClasses)
|
||||
.map(
|
||||
([domain, deviceClasses]) =>
|
||||
` "${domain}": [${deviceClasses.map((deviceClass) => `"${deviceClass}"`).join(", ")}],`
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
`;
|
||||
|
||||
await writeFile(TARGET, content);
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const SOURCE_URL =
|
||||
process.env.SENSOR_METADATA_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
|
||||
|
||||
const TARGET = join(
|
||||
paths.root_dir,
|
||||
"src",
|
||||
"data",
|
||||
"sensor_numeric_device_classes.ts"
|
||||
);
|
||||
|
||||
gulp.task("gen-numeric-device-classes", async () => {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const classes = [...(data.numeric_device_classes ?? [])].sort();
|
||||
if (!classes.length) {
|
||||
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
|
||||
}
|
||||
|
||||
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
|
||||
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
|
||||
// Regenerate with \`script/gen_numeric_device_classes\`.
|
||||
|
||||
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
|
||||
];
|
||||
`;
|
||||
|
||||
await writeFile(TARGET, content);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const SOURCE_URL =
|
||||
process.env.SENSOR_METADATA_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
|
||||
|
||||
const TARGET = join(
|
||||
paths.root_dir,
|
||||
"src",
|
||||
"data",
|
||||
"sensor_entity_constants.ts"
|
||||
);
|
||||
|
||||
gulp.task("gen-sensor-entity-constants", async () => {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
|
||||
const deviceClassUnits = data.device_class_units ?? {};
|
||||
const convertibleClassUnits = data.convertible_units ?? {};
|
||||
const stateClasses = data.state_classes ?? [];
|
||||
const stateClassUnits = data.state_class_units ?? {};
|
||||
if (
|
||||
!numericDeviceClasses.length ||
|
||||
!stateClasses.length ||
|
||||
!Object.keys(deviceClassUnits).length ||
|
||||
!Object.keys(stateClassUnits).length
|
||||
) {
|
||||
throw new Error(
|
||||
`No sensor device classes, state classes or units found in ${SOURCE_URL}`
|
||||
);
|
||||
}
|
||||
|
||||
const content = `// This file is auto-generated from Home Assistant Core's \`DEVICE_CLASS_UNITS\`
|
||||
// and \`STATE_CLASS_UNITS\`) and \`SensorDeviceClass\`
|
||||
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
|
||||
// Regenerate with \`script/gen_sensor_entity_constants\`.
|
||||
|
||||
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
${numericDeviceClasses.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
|
||||
];
|
||||
|
||||
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
|
||||
${Object.entries(deviceClassUnits)
|
||||
.map(
|
||||
([deviceClass, units]) =>
|
||||
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
|
||||
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<string, (string | null)[]> = {
|
||||
${Object.entries(convertibleClassUnits)
|
||||
.map(
|
||||
([deviceClass, units]) =>
|
||||
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
|
||||
export const SENSOR_STATE_CLASSES: string[] = [
|
||||
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
|
||||
];
|
||||
|
||||
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
|
||||
${Object.entries(stateClassUnits)
|
||||
.map(
|
||||
([stateClass, units]) =>
|
||||
` ${stateClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
`;
|
||||
|
||||
await writeFile(TARGET, content);
|
||||
});
|
||||
@@ -9,10 +9,12 @@ import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-device-classes.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./gen-numeric-device-classes.js";
|
||||
import "./gen-sensor-entity-constants.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./map-assets.js";
|
||||
import "./rspack.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Generates the MapLibre styles for the vector base map.
|
||||
//
|
||||
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
|
||||
// is what lets them be requested with an application User-Agent and without a
|
||||
// referrer. The styles stay here because they come from @versatiles/style and
|
||||
// core has no node toolchain to regenerate them with.
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { colorful, eclipse } from "@versatiles/style";
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
import { addLatinLabels } from "./map-labels.js";
|
||||
|
||||
const PROXY_PATH = "/api/map_tiles";
|
||||
const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`;
|
||||
|
||||
const outputDir = path.resolve(paths.build_dir, "map");
|
||||
|
||||
// MapLibre extends the fetched TileJSON with the style's source options, so
|
||||
// anything left here wins and freezes at build time. Dropping them is what lets
|
||||
// the proxy move the attribution and zoom range too, not just the URLs.
|
||||
const TILEJSON_FIELDS = [
|
||||
"tiles",
|
||||
"attribution",
|
||||
"bounds",
|
||||
"minzoom",
|
||||
"maxzoom",
|
||||
"scheme",
|
||||
];
|
||||
|
||||
// The builder can only write a tile URL, so the source is repointed afterwards.
|
||||
// Keyed on there being exactly one source: any other shape means the builder's
|
||||
// own default host would ship unnoticed.
|
||||
const useTileJson = (name, style) => {
|
||||
const sources = Object.values(style.sources);
|
||||
|
||||
if (sources.length !== 1) {
|
||||
throw new Error(
|
||||
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
|
||||
`point at the TileJSON. Check what @versatiles/style emits.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of TILEJSON_FIELDS) {
|
||||
delete sources[0][field];
|
||||
}
|
||||
sources[0].url = TILEJSON_URL;
|
||||
return style;
|
||||
};
|
||||
|
||||
const styleOptions = {
|
||||
// Keeps the generated URLs origin relative.
|
||||
baseUrl: "",
|
||||
glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
|
||||
sprite: [{ id: "basics", url: `${PROXY_PATH}/sprites/basics/sprites` }],
|
||||
};
|
||||
|
||||
const buildMapAssets = async () => {
|
||||
await fs.emptyDir(outputDir);
|
||||
|
||||
await Promise.all(
|
||||
// Both themes up front: dark is a real cartography, not an inverted raster.
|
||||
[
|
||||
["light", colorful],
|
||||
["dark", eclipse],
|
||||
].map(([name, builder]) =>
|
||||
writeFile(
|
||||
path.join(outputDir, `${name}.json`),
|
||||
JSON.stringify(addLatinLabels(useTileJson(name, builder(styleOptions))))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Shared so it does not have to be wired into every pipeline separately.
|
||||
let pending;
|
||||
export const ensureMapAssets = () => {
|
||||
pending ??= buildMapAssets();
|
||||
return pending;
|
||||
};
|
||||
|
||||
gulp.task("build-map-assets", ensureMapAssets);
|
||||
|
||||
export const mapAssetsDir = outputDir;
|
||||
@@ -0,0 +1,42 @@
|
||||
// Adds the English name to labels whose local name is not in Latin script.
|
||||
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
|
||||
|
||||
const NAME = ["get", "name"];
|
||||
const NAME_EN = ["get", "name_en"];
|
||||
|
||||
// Strings compare by code point: anything from Basic Latin up to Latin
|
||||
// Extended-B, digits and punctuation included.
|
||||
const IS_LATIN = ["<", NAME, "ɐ"];
|
||||
|
||||
const ENGLISH_SCALE = 0.8;
|
||||
|
||||
// Streets are line-placed and cannot break lines.
|
||||
const withEnglish = (placement) =>
|
||||
placement === "line"
|
||||
? ["concat", NAME, " (", NAME_EN, ")"]
|
||||
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
|
||||
|
||||
const isNameLabel = (layer) =>
|
||||
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
|
||||
|
||||
export const addLatinLabels = (style) => ({
|
||||
...style,
|
||||
layers: style.layers.map((layer) =>
|
||||
isNameLabel(layer)
|
||||
? {
|
||||
...layer,
|
||||
layout: {
|
||||
...layer.layout,
|
||||
"text-field": [
|
||||
"case",
|
||||
IS_LATIN,
|
||||
NAME,
|
||||
["!", ["has", "name_en"]],
|
||||
NAME,
|
||||
withEnglish(layer.layout["symbol-placement"]),
|
||||
],
|
||||
},
|
||||
}
|
||||
: layer
|
||||
),
|
||||
});
|
||||
@@ -1,38 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
|
||||
|
||||
import { version as babelVersion } from "@babel/core";
|
||||
import presetEnv from "@babel/preset-env";
|
||||
import compilationTargets from "@babel/helper-compilation-targets";
|
||||
import { transformSync } from "@babel/core";
|
||||
import compilationTargets, {
|
||||
getInclusionReasons,
|
||||
} from "@babel/helper-compilation-targets";
|
||||
import coreJSCompat from "core-js-compat";
|
||||
import { logPlugin } from "@babel/preset-env/lib/debug.js";
|
||||
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
|
||||
import { babelOptions } from "./bundle.cjs";
|
||||
|
||||
const detailsOpen = (heading) =>
|
||||
`<details>\n<summary><h4>${heading}</h4></summary>\n`;
|
||||
const detailsClose = "</details>\n";
|
||||
|
||||
const dummyAPI = {
|
||||
version: babelVersion,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
assertVersion: () => {},
|
||||
caller: (callback) =>
|
||||
callback({
|
||||
name: "Dummy Bundler",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
}),
|
||||
targets: () => ({}),
|
||||
// Copied from @babel/preset-env's internal `logPlugin`, which Babel 8 no
|
||||
// longer exposes (the package rolls up into lib/index.js and exports nothing
|
||||
// but the preset). Prints an item with the targets that require it.
|
||||
const logPlugin = (item, targetVersions, list) => {
|
||||
const filteredList = getInclusionReasons(item, targetVersions, list);
|
||||
const support = list[item];
|
||||
if (!support) {
|
||||
console.log(` ${item}`);
|
||||
return;
|
||||
}
|
||||
let formattedTargets = `{`;
|
||||
let first = true;
|
||||
for (const target of Object.keys(filteredList)) {
|
||||
if (!first) formattedTargets += `,`;
|
||||
first = false;
|
||||
formattedTargets += ` ${target}`;
|
||||
if (support[target]) formattedTargets += ` < ${support[target]}`;
|
||||
}
|
||||
formattedTargets += ` }`;
|
||||
console.log(` ${item} ${formattedTargets}`);
|
||||
};
|
||||
|
||||
// Copied from babel-plugin-polyfill-corejs3's generated
|
||||
// corejs3ShippedProposalsList, which v1 no longer exposes (it is inlined in
|
||||
// the package's rolled-up bundle).
|
||||
const shippedProposalsList = new Set([
|
||||
"esnext.array.group",
|
||||
"esnext.array.group-to-map",
|
||||
"esnext.iterator.zip",
|
||||
"esnext.iterator.zip-keyed",
|
||||
"esnext.symbol.metadata",
|
||||
]);
|
||||
|
||||
// Generate filter function based on proposal/method inputs
|
||||
// Copied and adapted from babel-plugin-polyfill-corejs3/esm/index.mjs
|
||||
const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
if (proposals || method === "entry-global") return true;
|
||||
if (shippedProposals && shippedPolyfills.default.has(name)) {
|
||||
if (shippedProposals && shippedProposalsList.has(name)) {
|
||||
return true;
|
||||
}
|
||||
if (name.startsWith("esnext.")) {
|
||||
@@ -47,7 +64,9 @@ const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
for (const buildType of ["Modern", "Legacy"]) {
|
||||
const browserslistEnv = buildType.toLowerCase();
|
||||
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
|
||||
const presetEnvOpts = babelOpts.presets[0][1];
|
||||
const presetEnvOpts = babelOpts.presets.find(
|
||||
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
|
||||
)?.[1];
|
||||
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
|
||||
// removed preset-env's `useBuiltIns`), so read its options here.
|
||||
const corejsOpts = babelOpts.plugins.find(
|
||||
@@ -55,22 +74,38 @@ for (const buildType of ["Modern", "Legacy"]) {
|
||||
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
|
||||
)?.[1];
|
||||
|
||||
// Invoking preset-env in debug mode will log the included plugins
|
||||
// Transforming an empty file with preset-env in debug mode logs the included
|
||||
// plugins. The caller declares the same capabilities babel-loader does, so
|
||||
// plugins gated on bundler support (e.g. transform-export-namespace-from)
|
||||
// match the build.
|
||||
presetEnvOpts.debug = true;
|
||||
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
|
||||
presetEnv.default(dummyAPI, {
|
||||
...presetEnvOpts,
|
||||
browserslistEnv,
|
||||
debug: true,
|
||||
transformSync("", {
|
||||
...babelOpts,
|
||||
configFile: false,
|
||||
filename: "audit.js",
|
||||
caller: {
|
||||
name: "list-plugins-and-polyfills",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
},
|
||||
});
|
||||
console.log(detailsClose);
|
||||
|
||||
// Manually log the Core-JS polyfills using the same technique
|
||||
if (corejsOpts) {
|
||||
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
|
||||
const targets = compilationTargets.default(babelOpts?.targets, {
|
||||
const targets = compilationTargets(babelOpts.targets, {
|
||||
browserslistEnv,
|
||||
});
|
||||
const polyfillList = coreJSCompat({ targets }).list.filter(
|
||||
// `version` limits the list to modules the installed core-js ships,
|
||||
// mirroring the provider's own filtering.
|
||||
const polyfillList = coreJSCompat({
|
||||
targets,
|
||||
version: corejsOpts.version,
|
||||
}).list.filter(
|
||||
polyfillFilter(
|
||||
corejsOpts.method,
|
||||
corejsOpts.proposals,
|
||||
|
||||
@@ -345,10 +345,7 @@ const createRspackConfig = ({
|
||||
"lit/directives/join$": "lit/directives/join.js",
|
||||
"lit/directives/repeat$": "lit/directives/repeat.js",
|
||||
"lit/directives/live$": "lit/directives/live.js",
|
||||
"lit/directives/keyed$": latestBuild
|
||||
? "lit/directives/keyed.js"
|
||||
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
|
||||
"lit/polyfill-support$": "lit/polyfill-support.js",
|
||||
"lit/directives/keyed$": "lit/directives/keyed.js",
|
||||
"@lit-labs/virtualizer/layouts/grid":
|
||||
"@lit-labs/virtualizer/layouts/grid.js",
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
saveTokens,
|
||||
} from "../../../../src/common/auth/token_storage";
|
||||
import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-icon";
|
||||
import "../../../../src/components/ha-list";
|
||||
@@ -197,8 +196,7 @@ class HcCast extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
toggleAttribute(
|
||||
this,
|
||||
this.toggleAttribute(
|
||||
"hide-icons",
|
||||
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { connectivityEntities } from "../stubs/connectivity/fixtures";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
@@ -53,6 +54,9 @@ export const setDemoConfig = async (
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
// Replaced the whole state map above, so the entities that do not belong to a
|
||||
// demo config have to be added back.
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Let the new registries and entities reach the dashboard before saving the
|
||||
// config, so dashboard strategies generate against them
|
||||
|
||||
+11
-1
@@ -7,6 +7,12 @@ import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import {
|
||||
connectivityCommands,
|
||||
connectivityComponents,
|
||||
connectivityEntities,
|
||||
connectivityEntityRegistryEntries,
|
||||
} from "./stubs/connectivity/fixtures";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
@@ -60,6 +66,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
...connectivityCommands,
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -87,6 +94,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
...connectivityComponents,
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -99,7 +107,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
mockLovelace(hass, localizePromise);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass);
|
||||
mockTranslations(hass, localizePromise);
|
||||
mockHistory(hass);
|
||||
mockRecorder(hass);
|
||||
mockTodo(hass);
|
||||
@@ -172,9 +180,11 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
...connectivityEntityRegistryEntries,
|
||||
]);
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Once config is loaded AND localize, set registries, entities and theme.
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mockBlueprint } from "./blueprint";
|
||||
import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockConnectivity } from "./connectivity";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
@@ -26,6 +27,7 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockCloud(hass);
|
||||
mockConfig(hass);
|
||||
mockConfigEntries(hass);
|
||||
mockConnectivity(hass);
|
||||
mockDeviceAutomation(hass);
|
||||
mockEntitySources(hass);
|
||||
mockBlueprint(hass);
|
||||
@@ -43,4 +45,10 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
hass.mockWS("llm/api/list", () => ({
|
||||
apis: [
|
||||
{ id: "assist", name: "Assist" },
|
||||
{ id: "music_assistant", name: "Music Assistant" },
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
|
||||
import type { IntegrationType } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityConfigEntries } from "./connectivity/fixtures";
|
||||
|
||||
const baseEntry = {
|
||||
source: "user",
|
||||
@@ -19,6 +20,7 @@ const baseEntry = {
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
@@ -79,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: {},
|
||||
@@ -51,4 +52,40 @@ export const demoDevices: DeviceRegistryEntry[] = [
|
||||
primary_config_entry: "mock-sonos",
|
||||
entry_type: null,
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip",
|
||||
name: "Power strip",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
},
|
||||
// Child devices (logical parts of the power strip). They carry the parent's
|
||||
// inherited hardware fields, mirroring how resolveChildDevices fills them in
|
||||
// from the WebSocket, and reference the parent via parent_device_id.
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-1",
|
||||
name: "Outlet 1",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-2",
|
||||
name: "Outlet 2",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
...connectivityDevices,
|
||||
];
|
||||
|
||||
@@ -102,6 +102,11 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
cost_sensors: {},
|
||||
solar_forecast_domains: [],
|
||||
}));
|
||||
hass.mockWS("energy/validate", () => ({
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
({ period }): FossilEnergyConsumption => ({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSensor = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"sensor/device_class_convertible_units",
|
||||
({ device_class }: { device_class: string }) => ({
|
||||
units: device_class === "energy" ? ["kWh"] : ["W"],
|
||||
})
|
||||
);
|
||||
hass.mockWS("sensor/numeric_device_classes", () => ({
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
|
||||
+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;
|
||||
|
||||
@@ -25,6 +25,9 @@ title: Button
|
||||
<ha-button appearance="filled">
|
||||
filled button
|
||||
</ha-button>
|
||||
<ha-button appearance="outlined">
|
||||
outlined button
|
||||
</ha-button>
|
||||
|
||||
<ha-button size="s">
|
||||
small
|
||||
@@ -65,7 +68,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
|
||||
| appearance | "accent"/"filled"/"outlined"/"plain" | "accent" | Sets the button appearance. |
|
||||
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
|
||||
| size | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
|
||||
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
|
||||
|
||||
@@ -9,7 +9,7 @@ import "../../../../src/components/ha-svg-icon";
|
||||
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const appearances = ["accent", "filled", "plain"];
|
||||
const appearances = ["accent", "filled", "outlined", "plain"];
|
||||
const variants = ["brand", "danger", "neutral", "warning", "success"];
|
||||
|
||||
@customElement("demo-components-ha-button")
|
||||
|
||||
@@ -29,6 +29,50 @@ const positions: Position[] = ["start", "end"];
|
||||
const selectedStates = [false, true];
|
||||
const disabledStates = [false, true];
|
||||
|
||||
interface TreeChild {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TreeGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
children: TreeChild[];
|
||||
}
|
||||
|
||||
const treeGroups: TreeGroup[] = [
|
||||
{
|
||||
key: "binary_sensor",
|
||||
label: "Binary sensor",
|
||||
children: [
|
||||
{ key: "door", label: "Door" },
|
||||
{ key: "motion", label: "Motion" },
|
||||
{ key: "window", label: "Window" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cover",
|
||||
label: "Cover",
|
||||
children: [
|
||||
{ key: "garage", label: "Garage" },
|
||||
{ key: "shutter", label: "Shutter" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface TreeRow {
|
||||
group: TreeGroup;
|
||||
child?: TreeChild;
|
||||
}
|
||||
|
||||
const treeRows: TreeRow[] = treeGroups.flatMap((group) => [
|
||||
{ group },
|
||||
...group.children.map((child) => ({ group, child })),
|
||||
]);
|
||||
|
||||
const treeKey = (group: TreeGroup, child: TreeChild) =>
|
||||
`${group.key}/${child.key}`;
|
||||
|
||||
@customElement("demo-components-ha-list")
|
||||
export class DemoHaList extends LitElement {
|
||||
@state() private _buttonClicks = 0;
|
||||
@@ -41,6 +85,8 @@ export class DemoHaList extends LitElement {
|
||||
|
||||
@state() private _multiCheckEnd: number | Set<number> = new Set();
|
||||
|
||||
@state() private _tree = new Set<string>();
|
||||
|
||||
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -274,6 +320,45 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Controlled selection with indeterminate groups">
|
||||
<ha-list-selectable
|
||||
multi
|
||||
controlled
|
||||
aria-label="Controlled tree"
|
||||
@ha-list-item-selected=${this._onTreeToggle}
|
||||
@ha-list-item-deselected=${this._onTreeToggle}
|
||||
>
|
||||
${treeGroups.map((group) => {
|
||||
const groupState = this._groupState(group);
|
||||
return html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${group.key}
|
||||
?selected=${groupState === "all"}
|
||||
?indeterminate=${groupState === "some"}
|
||||
>
|
||||
<span slot="headline">${group.label}</span>
|
||||
</ha-list-item-option>
|
||||
${group.children.map(
|
||||
(child) => html`
|
||||
<ha-list-item-option
|
||||
class="child"
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${treeKey(group, child)}
|
||||
?selected=${this._tree.has(treeKey(group, child))}
|
||||
>
|
||||
<span slot="headline">${child.label}</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</ha-list-selectable>
|
||||
<pre>selected: ${JSON.stringify([...this._tree])}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Option: all combinations">
|
||||
<div class="grid">
|
||||
${appearances.map((appearance) =>
|
||||
@@ -361,6 +446,43 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
return next;
|
||||
}
|
||||
|
||||
private _groupState(group: TreeGroup): "none" | "some" | "all" {
|
||||
const selected = group.children.filter((child) =>
|
||||
this._tree.has(treeKey(group, child))
|
||||
).length;
|
||||
if (selected === 0) {
|
||||
return "none";
|
||||
}
|
||||
return selected === group.children.length ? "all" : "some";
|
||||
}
|
||||
|
||||
private _onTreeToggle = (ev: CustomEvent<number>) => {
|
||||
const row = treeRows[ev.detail];
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const next = new Set(this._tree);
|
||||
if (row.child) {
|
||||
const key = treeKey(row.group, row.child);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
} else {
|
||||
const select = this._groupState(row.group) !== "all";
|
||||
row.group.children.forEach((child) => {
|
||||
const key = treeKey(row.group, child);
|
||||
if (select) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
this._tree = next;
|
||||
};
|
||||
|
||||
private _onSingle = (ev: CustomEvent<number>) => {
|
||||
this._single = ev.detail;
|
||||
};
|
||||
@@ -443,6 +565,9 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.child::part(base) {
|
||||
padding-inline-start: var(--ha-space-12);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
@@ -9,7 +8,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import "../../../../src/components/ha-target-picker";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
|
||||
@@ -155,9 +153,6 @@ interface Sample {
|
||||
description: string;
|
||||
selector: Selector;
|
||||
value: unknown;
|
||||
// Render ha-target-picker directly in compact (chip) mode instead of the
|
||||
// ha-selector, which does not expose the compact option.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SAMPLES: Sample[] = [
|
||||
@@ -168,14 +163,6 @@ const SAMPLES: Sample[] = [
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
},
|
||||
{
|
||||
name: "Target (compact)",
|
||||
description:
|
||||
"In compact mode the replaced reference is shown as a warning chip.",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
name: "Device (unfiltered, multiple matches)",
|
||||
description:
|
||||
@@ -274,23 +261,13 @@ class DemoHaSelectorReplacedDevice
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${sample.name}</span>
|
||||
<span slot="description">${sample.description}</span>
|
||||
${
|
||||
sample.compact
|
||||
? html`<ha-target-picker
|
||||
compact
|
||||
.hass=${this.hass}
|
||||
.value=${this._values[idx] as HassServiceTarget}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-target-picker>`
|
||||
: html`<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>`
|
||||
}
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>
|
||||
</ha-settings-row>
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -152,6 +152,84 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_power_strip",
|
||||
identifiers: [["demo", "strip1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Power strip",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
// Child devices of the power strip. They have no area of their own and
|
||||
// inherit the parent's area ("Livingroom"); the picker renders them indented
|
||||
// under the parent with a tree connector.
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_1",
|
||||
identifiers: [["demo", "outlet1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 1",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_2",
|
||||
identifiers: [["demo", "outlet2"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 2",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: DemoArea[] = [
|
||||
@@ -346,6 +424,23 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
device_class: {
|
||||
name: "Device Class",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
},
|
||||
},
|
||||
},
|
||||
device_class_multiple: {
|
||||
name: "Device Class (Multiple)",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "binary_sensor",
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select_custom: {
|
||||
name: "Select (Custom)",
|
||||
selector: {
|
||||
|
||||
@@ -132,12 +132,12 @@ const ENTITIES = [
|
||||
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
|
||||
preset_modes: ["home", "eco", "away"],
|
||||
swing_modes: ["auto", "1", "2", "3", "off"],
|
||||
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
current_temperature: 23,
|
||||
target_temp_high: 24,
|
||||
target_temp_low: 21,
|
||||
fan_mode: "auto_low",
|
||||
preset_mode: "home",
|
||||
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
swing_mode: "auto",
|
||||
swing_horizontal_mode: "off",
|
||||
supported_features:
|
||||
@@ -340,6 +340,84 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-oscillate" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: one feature",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: two features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: three features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: four features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Bottom features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "bottom",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
|
||||
@@ -39,6 +39,7 @@ const createConfigEntry = (
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
...override,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -15,7 +15,6 @@ const ALL_FEATURES =
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.FAN_SPEED +
|
||||
VacuumEntityFeature.BATTERY +
|
||||
VacuumEntityFeature.STATUS +
|
||||
VacuumEntityFeature.LOCATE +
|
||||
VacuumEntityFeature.CLEAN_SPOT +
|
||||
@@ -28,8 +27,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Full featured vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 85,
|
||||
battery_icon: "mdi:battery-80",
|
||||
fan_speed: "balanced",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Charged",
|
||||
@@ -41,8 +38,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Cleaning vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 62,
|
||||
battery_icon: "mdi:battery-60",
|
||||
fan_speed: "turbo",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Cleaning bedroom",
|
||||
@@ -58,10 +53,7 @@ const ENTITIES = [
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.BATTERY,
|
||||
battery_level: 23,
|
||||
battery_icon: "mdi:battery-20",
|
||||
VacuumEntityFeature.RETURN_HOME,
|
||||
status: "Returning to dock",
|
||||
},
|
||||
},
|
||||
@@ -96,8 +88,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Paused vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 45,
|
||||
battery_icon: "mdi:battery-40",
|
||||
fan_speed: "standard",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Paused",
|
||||
|
||||
+37
-35
@@ -42,17 +42,17 @@
|
||||
"@babel/runtime": "8.0.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/commands": "6.11.0",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@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.8",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.3",
|
||||
"@codemirror/view": "6.43.11",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.6.0",
|
||||
"@formatjs/intl-datetimeformat": "7.6.1",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
@@ -75,6 +75,8 @@
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.5.0",
|
||||
@@ -83,38 +85,37 @@
|
||||
"@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.1",
|
||||
"barcode-detector": "3.2.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"cropperjs": "1.6.3",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.6.17",
|
||||
"hls.js": "1.7.2",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"js-yaml": "5.2.3",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.4.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.9",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.11",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -142,18 +143,18 @@
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.1",
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
"@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.1",
|
||||
"@rspack/core": "2.1.8",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@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",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -161,20 +162,21 @@
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.4",
|
||||
"@types/luxon": "3.7.5",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@versatiles/style": "5.13.1",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.8.1",
|
||||
"eslint": "10.9.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -184,9 +186,9 @@
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.9.0",
|
||||
"globals": "17.12.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -195,12 +197,13 @@
|
||||
"jsdom": "30.0.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.3.0",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.4.1",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"minify-literals": "2.2.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
@@ -210,9 +213,9 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.66.0",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"vitest": "4.1.11",
|
||||
"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"
|
||||
@@ -223,12 +226,11 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.9.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
"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 = "20260729.0"
|
||||
version = "20260826.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -27,6 +27,7 @@ const ALLOWED_LICENSES = new Set([
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
|
||||
@@ -8,4 +8,4 @@ set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-numeric-device-classes
|
||||
./node_modules/.bin/gulp gen-device-classes
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Safe bash settings
|
||||
# -e Exit on command fail
|
||||
# -u Exit on unset variable
|
||||
# -o pipefail Exit if piped command has error code
|
||||
set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-sensor-entity-constants
|
||||
@@ -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,216 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import { css, type LitElement } from "lit";
|
||||
import type { Ref } from "lit/directives/ref";
|
||||
import { parseAnimationDuration } from "../util/parse-animation-duration";
|
||||
|
||||
type FilterPanelHost = ReactiveControllerHost &
|
||||
LitElement & { expanded: boolean };
|
||||
|
||||
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
|
||||
/**
|
||||
* Layout the controller relies on: the filter is a flex column made of its
|
||||
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
|
||||
* tall as its header; expanded, it fills what is left of the pane and hands
|
||||
* that space down to the list through the wrapper.
|
||||
*/
|
||||
export const filterPanelStyles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
flex: none;
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
ha-expansion-panel::part(summary) {
|
||||
user-select: none;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const panels = new Set<FilterPanelController>();
|
||||
|
||||
let pending: Set<FilterPanelController> | undefined;
|
||||
|
||||
const flush = () => {
|
||||
const batch = [...pending!].filter((panel) => panel.host.isConnected);
|
||||
pending = undefined;
|
||||
batch.forEach((panel) => panel.prepare());
|
||||
batch.forEach((panel) => panel.measure());
|
||||
batch.forEach((panel) => panel.play());
|
||||
};
|
||||
|
||||
// Filters that change in the same frame (one closing while another opens) are
|
||||
// animated as one batch: every filter is measured before any of them has
|
||||
// changed the DOM, and the closing ones are parked at their final height so
|
||||
// the opening one reads its own final height from the real layout.
|
||||
const enqueue = (panel: FilterPanelController) => {
|
||||
if (!pending) {
|
||||
pending = new Set();
|
||||
panels.forEach((other) => other.snapshot());
|
||||
requestAnimationFrame(flush);
|
||||
}
|
||||
pending.add(panel);
|
||||
};
|
||||
|
||||
const clearInlineStyles = (element?: HTMLElement) => {
|
||||
element?.style.removeProperty("height");
|
||||
element?.style.removeProperty("flex");
|
||||
element?.style.removeProperty("overflow");
|
||||
};
|
||||
|
||||
/**
|
||||
* Animates a filter of the filter pane between its collapsed and expanded
|
||||
* heights whenever `expanded` changes, and tells the host when to render its
|
||||
* content: from the moment it expands until its collapse animation has ended.
|
||||
*
|
||||
* During the animation the content keeps its final size and the host clips
|
||||
* it, so the list is revealed rather than resized.
|
||||
*/
|
||||
export class FilterPanelController implements ReactiveController {
|
||||
public showContent = false;
|
||||
|
||||
public host: FilterPanelHost;
|
||||
|
||||
private _content: Ref<HTMLElement>;
|
||||
|
||||
private _expanded?: boolean;
|
||||
|
||||
private _first = 0;
|
||||
|
||||
private _last = 0;
|
||||
|
||||
private _contentHeight = 0;
|
||||
|
||||
private _animation?: Animation;
|
||||
|
||||
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
|
||||
this.host = host;
|
||||
this._content = content;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected() {
|
||||
panels.add(this);
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
panels.delete(this);
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
clearInlineStyles(this.host);
|
||||
clearInlineStyles(this._content.value);
|
||||
}
|
||||
|
||||
public hostUpdate() {
|
||||
const expanded = this.host.expanded;
|
||||
if (this._expanded === undefined) {
|
||||
this._expanded = expanded;
|
||||
this.showContent = expanded;
|
||||
return;
|
||||
}
|
||||
if (expanded === this._expanded) {
|
||||
return;
|
||||
}
|
||||
this._expanded = expanded;
|
||||
if (!this.host.isConnected) {
|
||||
this.showContent = expanded;
|
||||
return;
|
||||
}
|
||||
if (expanded) {
|
||||
this.showContent = true;
|
||||
}
|
||||
enqueue(this);
|
||||
}
|
||||
|
||||
public snapshot() {
|
||||
this._first = this.host.getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
public prepare() {
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
const host = this.host;
|
||||
const content = this._content.value;
|
||||
clearInlineStyles(host);
|
||||
clearInlineStyles(content);
|
||||
if (host.expanded) {
|
||||
return;
|
||||
}
|
||||
this._last =
|
||||
host.getBoundingClientRect().height -
|
||||
(content?.getBoundingClientRect().height ?? 0);
|
||||
this._contentHeight = this._first - this._last;
|
||||
host.style.flex = "none";
|
||||
host.style.height = `${this._last}px`;
|
||||
}
|
||||
|
||||
public measure() {
|
||||
if (!this.host.expanded) {
|
||||
return;
|
||||
}
|
||||
this._last = this.host.getBoundingClientRect().height;
|
||||
this._contentHeight =
|
||||
this._content.value?.getBoundingClientRect().height ?? 0;
|
||||
}
|
||||
|
||||
public play() {
|
||||
const host = this.host;
|
||||
if (this._first === this._last) {
|
||||
this._finish();
|
||||
return;
|
||||
}
|
||||
const content = this._content.value;
|
||||
host.style.flex = "none";
|
||||
host.style.overflow = "hidden";
|
||||
if (content) {
|
||||
content.style.flex = "none";
|
||||
content.style.height = `${this._contentHeight}px`;
|
||||
}
|
||||
const animation = host.animate(
|
||||
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
|
||||
{
|
||||
duration:
|
||||
parseAnimationDuration(
|
||||
getComputedStyle(host).getPropertyValue(
|
||||
"--ha-animation-duration-normal"
|
||||
)
|
||||
) || 250,
|
||||
easing: EASING,
|
||||
fill: "forwards",
|
||||
}
|
||||
);
|
||||
animation.onfinish = () => this._finish(animation);
|
||||
this._animation = animation;
|
||||
}
|
||||
|
||||
private async _finish(animation?: Animation) {
|
||||
if (!this.host.expanded) {
|
||||
this.showContent = false;
|
||||
this.host.requestUpdate();
|
||||
await this.host.updateComplete;
|
||||
}
|
||||
if (this._animation !== animation) {
|
||||
return;
|
||||
}
|
||||
clearInlineStyles(this.host);
|
||||
clearInlineStyles(this._content.value);
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,25 @@ const formatTimeWithSecondsMem = memoizeOne(
|
||||
})
|
||||
);
|
||||
|
||||
// 9:15:24.123 PM || 21:15:24,123
|
||||
export const formatTimeWithMilliseconds = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
|
||||
|
||||
const formatTimeWithMillisecondsMem = memoizeOne(
|
||||
(locale: FrontendLocaleData, serverTimeZone: string) =>
|
||||
new Intl.DateTimeFormat(locale.language, {
|
||||
hour: useAmPm(locale) ? "numeric" : "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: useAmPm(locale) ? "h12" : "h23",
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
// Tuesday 7:00 PM || Tuesday 19:00
|
||||
export const formatTimeWeekday = (
|
||||
dateObj: Date,
|
||||
|
||||
@@ -145,16 +145,11 @@ export const applyThemesOnElement = (
|
||||
element.__themes = { cacheKey, keys: newTheme?.keys };
|
||||
|
||||
// Set and/or reset styles
|
||||
if (window.ShadyCSS) {
|
||||
// Use ShadyCSS if available
|
||||
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
|
||||
} else {
|
||||
for (const s in styles) {
|
||||
if (s === null) {
|
||||
element.style.removeProperty(s);
|
||||
} else {
|
||||
element.style.setProperty(s, styles[s]);
|
||||
}
|
||||
for (const s in styles) {
|
||||
if (s === null) {
|
||||
element.style.removeProperty(s);
|
||||
} else {
|
||||
element.style.setProperty(s, styles[s]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import type { Map, TileLayer } from "leaflet";
|
||||
import type { Map } from "leaflet";
|
||||
import type { MapBaseLayer } from "../map/base-layer";
|
||||
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
|
||||
|
||||
// Sets up a Leaflet map on the provided DOM element
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletModuleType = typeof import("leaflet");
|
||||
|
||||
export interface LeafletMapSetup {
|
||||
map: Map;
|
||||
leaflet: LeafletModuleType;
|
||||
baseLayer: MapBaseLayer;
|
||||
}
|
||||
|
||||
export const setupLeafletMap = async (
|
||||
mapElement: HTMLElement,
|
||||
initialView?: { latitude: number; longitude: number; zoom?: number }
|
||||
): Promise<[Map, LeafletModuleType, TileLayer]> => {
|
||||
initialView?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
darkMode?: boolean;
|
||||
token?: string;
|
||||
}
|
||||
): Promise<LeafletMapSetup> => {
|
||||
if (!mapElement.parentNode) {
|
||||
throw new Error("Cannot setup Leaflet map on disconnected element");
|
||||
}
|
||||
@@ -17,7 +31,11 @@ export const setupLeafletMap = async (
|
||||
|
||||
await import("leaflet.markercluster");
|
||||
|
||||
const map = Leaflet.map(mapElement);
|
||||
const map = Leaflet.map(mapElement, {
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
});
|
||||
map.attributionControl.setPrefix("");
|
||||
const style = document.createElement("link");
|
||||
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
|
||||
style.setAttribute("rel", "stylesheet");
|
||||
@@ -38,21 +56,14 @@ export const setupLeafletMap = async (
|
||||
);
|
||||
}
|
||||
|
||||
const tileLayer = createTileLayer(Leaflet).addTo(map);
|
||||
|
||||
return [map, Leaflet, tileLayer];
|
||||
};
|
||||
|
||||
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
|
||||
leaflet.tileLayer(
|
||||
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
|
||||
leaflet.Browser.retina ? "@2x.png" : ".png"
|
||||
}`,
|
||||
{
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: "abcd",
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
}
|
||||
// The base layer adds itself: the vector layer only builds its MapLibre map
|
||||
// once it is on the map, and that failing has to fall back to raster.
|
||||
const baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
initialView?.darkMode ?? false,
|
||||
initialView?.token
|
||||
);
|
||||
|
||||
return { map, leaflet: Leaflet, baseLayer };
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Toggle Attribute Polyfill because it's too new for some browsers
|
||||
export const toggleAttribute = (
|
||||
el: HTMLElement,
|
||||
name: string,
|
||||
force?: boolean
|
||||
) => {
|
||||
if (force !== undefined) {
|
||||
force = !!force;
|
||||
}
|
||||
|
||||
if (el.hasAttribute(name)) {
|
||||
if (force) {
|
||||
return true;
|
||||
}
|
||||
|
||||
el.removeAttribute(name);
|
||||
return false;
|
||||
}
|
||||
if (force === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
el.setAttribute(name, "");
|
||||
return true;
|
||||
};
|
||||
@@ -33,7 +33,10 @@ const normalizeFilterArray = <T>(
|
||||
};
|
||||
|
||||
export const generateEntityFilter = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>,
|
||||
filter: EntityFilter
|
||||
): EntityFilterFunc => {
|
||||
const domains = filter.domain
|
||||
|
||||
@@ -304,6 +304,9 @@ export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
|
||||
swing_mode: "swing_modes",
|
||||
swing_horizontal_mode: "swing_horizontal_modes",
|
||||
},
|
||||
cover: {
|
||||
speed: "supported_speeds",
|
||||
},
|
||||
event: {
|
||||
event_type: "event_types",
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const webComponentsSupported = "attachShadow" in Element.prototype;
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* ES5-compatible implementation of the keyed directive.
|
||||
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
|
||||
*
|
||||
* This implementation avoids parameter destructuring in the update() method,
|
||||
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
|
||||
*
|
||||
* Used only for ES5 builds (legacy browsers). Modern builds use the original
|
||||
* lit-html keyed directive.
|
||||
*
|
||||
* @see https://github.com/home-assistant/frontend/issues/28732
|
||||
*/
|
||||
import { directive, Directive } from "lit-html/directive.js";
|
||||
import { setCommittedValue } from "lit-html/directive-helpers.js";
|
||||
// eslint-disable-next-line lit/no-legacy-imports
|
||||
import { nothing } from "lit-html";
|
||||
import type { Part } from "lit-html/directive.js";
|
||||
|
||||
class KeyedES5 extends Directive {
|
||||
private _key: unknown = nothing;
|
||||
|
||||
render(k: unknown, v: unknown) {
|
||||
this._key = k;
|
||||
return v;
|
||||
}
|
||||
|
||||
update(part: unknown, args: [unknown, unknown]) {
|
||||
const k = args[0];
|
||||
const v = args[1];
|
||||
if (k !== this._key) {
|
||||
// Clear the part before returning a value. The one-arg form of
|
||||
// setCommittedValue sets the value to a sentinel which forces a
|
||||
// commit the next render.
|
||||
setCommittedValue(part as Part);
|
||||
this._key = k;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a renderable value with a unique key. When the key changes, the
|
||||
* previous DOM is removed and disposed before rendering the next value, even
|
||||
* if the value - such as a template - is the same.
|
||||
*
|
||||
* This is useful for forcing re-renders of stateful components, or working
|
||||
* with code that expects new data to generate new HTML elements, such as some
|
||||
* animation techniques.
|
||||
*/
|
||||
export const keyed = directive(KeyedES5);
|
||||
@@ -0,0 +1,333 @@
|
||||
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
|
||||
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
|
||||
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
|
||||
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
|
||||
import {
|
||||
MAP_TILES_PATH,
|
||||
mapTilesUrl,
|
||||
refreshMapTilesToken,
|
||||
subscribeMapTilesToken,
|
||||
withMapTilesToken,
|
||||
} from "../../data/map_tiles";
|
||||
|
||||
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
|
||||
// TileJSON, deliberately: it follows whoever serves the tiles.
|
||||
export const VECTOR_STYLES = {
|
||||
light: "/static/map/light.json",
|
||||
dark: "/static/map/dark.json",
|
||||
} as const;
|
||||
|
||||
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
|
||||
// worker, hence a URL rather than an import.
|
||||
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
|
||||
|
||||
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
|
||||
// OSM serves no @2x variant.
|
||||
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
|
||||
// The demo has no proxy to go through. Upstream serves raster to a browser that
|
||||
// identifies itself with a referrer, which the demo page's `same-origin` meta
|
||||
// policy strips again unless the tiles ask for it back.
|
||||
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
const OSM_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
|
||||
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
|
||||
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
|
||||
export const CONTEXT_RESTORE_GRACE = 2000;
|
||||
export const RECOVERY_THROTTLE = 30000;
|
||||
|
||||
// On the map, not the layer: marker clustering throws without a maximum. The
|
||||
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
|
||||
export const MAP_MIN_ZOOM = 1;
|
||||
export const MAP_MAX_ZOOM = 20;
|
||||
// OSM's raster stops at 19 and the proxy refuses higher, so Leaflet scales the
|
||||
// last level up rather than asking for tiles that are not there.
|
||||
const RASTER_MAX_NATIVE_ZOOM = 19;
|
||||
|
||||
// Leaflet substitutes any option into the URL template; its types do not.
|
||||
type TokenTileLayerOptions = TileLayerOptions & { token?: string };
|
||||
|
||||
export interface MapBaseLayer {
|
||||
// A no-op for raster, which has no dark variant and is inverted in CSS.
|
||||
setDarkMode: (darkMode: boolean) => void;
|
||||
}
|
||||
|
||||
let webGL2Supported: boolean | undefined;
|
||||
|
||||
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
|
||||
export const supportsWebGL2 = (): boolean => {
|
||||
if (webGL2Supported === undefined) {
|
||||
try {
|
||||
const context = document.createElement("canvas").getContext("webgl2");
|
||||
webGL2Supported = Boolean(context);
|
||||
// Contexts are scarce; the probe must not keep one.
|
||||
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
} catch {
|
||||
webGL2Supported = false;
|
||||
}
|
||||
}
|
||||
return webGL2Supported;
|
||||
};
|
||||
|
||||
// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would
|
||||
// mangle its {fontstack} and {range} placeholders.
|
||||
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
|
||||
// its glyphs and sprites, which is why those come from VersaTiles.
|
||||
const DEMO_UPSTREAM = {
|
||||
tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json",
|
||||
assets: "https://tiles.versatiles.org/assets",
|
||||
};
|
||||
|
||||
const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
|
||||
style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`;
|
||||
style.sprite = [
|
||||
{ id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` },
|
||||
];
|
||||
Object.values(style.sources).forEach((source) => {
|
||||
if ("url" in source) {
|
||||
source.url = DEMO_UPSTREAM.tilejson;
|
||||
}
|
||||
});
|
||||
return style;
|
||||
};
|
||||
|
||||
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
const style: StyleSpecification = await (await fetch(url)).json();
|
||||
|
||||
if (__DEMO__) {
|
||||
return useDemoUpstream(style);
|
||||
}
|
||||
|
||||
if (typeof style.sprite === "string") {
|
||||
style.sprite = mapTilesUrl(style.sprite);
|
||||
} else if (Array.isArray(style.sprite)) {
|
||||
style.sprite = style.sprite.map((sprite) => ({
|
||||
...sprite,
|
||||
url: mapTilesUrl(sprite.url),
|
||||
}));
|
||||
}
|
||||
return style;
|
||||
};
|
||||
|
||||
// Global to MapLibre, and it throws when set twice.
|
||||
let rtlTextPluginRequested = false;
|
||||
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
if (rtlTextPluginRequested) {
|
||||
return;
|
||||
}
|
||||
rtlTextPluginRequested = true;
|
||||
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
|
||||
() => {
|
||||
// RTL labels stay reversed; everything else still renders.
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createVectorLayer = async (
|
||||
createLayer: typeof maplibreGL,
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean,
|
||||
token: string | undefined
|
||||
): Promise<MapBaseLayer | undefined> => {
|
||||
let layer: ReturnType<typeof maplibreGL> | undefined;
|
||||
|
||||
try {
|
||||
layer = createLayer({
|
||||
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
|
||||
// Absolute, or the worker fetching tiles cannot resolve them.
|
||||
transformRequest: (url) => ({
|
||||
url: withMapTilesToken(url),
|
||||
// OSM asks a website for a referrer, and the demo has no instance
|
||||
// hostname to leak.
|
||||
referrerPolicy: __DEMO__ ? "origin" : undefined,
|
||||
}),
|
||||
});
|
||||
// The plugin builds the MapLibre map in `onAdd`, so a refused context or a
|
||||
// blocked worker throws here. Keep it guarded or those lose the fallback.
|
||||
layer.addTo(map);
|
||||
} catch {
|
||||
if (layer) {
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
// May never have finished being added.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Tracked apart so a failed request rolls back to what is displayed, not to
|
||||
// the opposite of what it asked - which with several in flight differs.
|
||||
let appliedDarkMode = darkMode;
|
||||
let requestedDarkMode = darkMode;
|
||||
// Styles are fetched, so only the newest request may touch the map.
|
||||
let latestRequest = 0;
|
||||
let vector = true;
|
||||
let refused = false;
|
||||
|
||||
const glMap = layer.getMaplibreMap();
|
||||
let fallbackTimeout: number | undefined;
|
||||
let contextLost = false;
|
||||
|
||||
// Declared first, but only ever called once all three exist.
|
||||
const handleVisibilityChange = () => {
|
||||
if (contextLost) {
|
||||
scheduleSwap();
|
||||
}
|
||||
};
|
||||
|
||||
const swapToRaster = () => {
|
||||
vector = false;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
// Nothing left to detach.
|
||||
}
|
||||
createRasterLayer(leaflet, map, token);
|
||||
};
|
||||
|
||||
const scheduleSwap = () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
// Backgrounding drops it too, and there it comes back on return.
|
||||
if (!vector || document.hidden) {
|
||||
return;
|
||||
}
|
||||
fallbackTimeout = window.setTimeout(swapToRaster, CONTEXT_RESTORE_GRACE);
|
||||
};
|
||||
|
||||
glMap.on("webglcontextlost", () => {
|
||||
contextLost = true;
|
||||
scheduleSwap();
|
||||
});
|
||||
glMap.on("webglcontextrestored", () => {
|
||||
contextLost = false;
|
||||
clearTimeout(fallbackTimeout);
|
||||
});
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
map.on("unload", () => {
|
||||
// Otherwise the timer revives a map that is already gone.
|
||||
clearTimeout(fallbackTimeout);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
|
||||
const applyStyle = (newDarkMode: boolean) => {
|
||||
const request = ++latestRequest;
|
||||
|
||||
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
|
||||
.then((style) => {
|
||||
if (request === latestRequest) {
|
||||
appliedDarkMode = newDarkMode;
|
||||
layer.getMaplibreMap()?.setStyle(style);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (request === latestRequest) {
|
||||
requestedDarkMode = appliedDarkMode;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// A refused request leaves the source dead: the TileJSON is fetched once and
|
||||
// is never retried, so the style has to be applied again once there is a new
|
||||
// token. Throttled, or a proxy refusing for another reason loops.
|
||||
let lastRecovery = 0;
|
||||
glMap.on("error", (event) => {
|
||||
const status = (event.error as { status?: number } | undefined)?.status;
|
||||
// 403 is a stale token, 404 the proxy not registered yet during a restart,
|
||||
// and no status at all a network failure. All three recover the same way,
|
||||
// and a token that comes back unchanged costs nothing.
|
||||
if (status !== undefined && status !== 403 && status !== 404) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
lastRecovery = Date.now();
|
||||
refused = true;
|
||||
refreshMapTilesToken();
|
||||
});
|
||||
|
||||
// Only a new token clears the refusal. A theme change in between applies a
|
||||
// style that is refused just as the last one was, so it proves nothing.
|
||||
const unsubscribeToken = subscribeMapTilesToken(() => {
|
||||
if (vector && refused) {
|
||||
refused = false;
|
||||
applyStyle(requestedDarkMode);
|
||||
}
|
||||
});
|
||||
map.on("unload", unsubscribeToken);
|
||||
|
||||
return {
|
||||
setDarkMode: (newDarkMode: boolean) => {
|
||||
if (!vector || newDarkMode === requestedDarkMode) {
|
||||
return;
|
||||
}
|
||||
requestedDarkMode = newDarkMode;
|
||||
applyStyle(newDarkMode);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createRasterLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
token: string | undefined
|
||||
): MapBaseLayer => {
|
||||
const layer = leaflet
|
||||
.tileLayer(__DEMO__ ? DEMO_RASTER_TILE_URL : mapTilesUrl(RASTER_TILE_URL), {
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
maxNativeZoom: RASTER_MAX_NATIVE_ZOOM,
|
||||
referrerPolicy: __DEMO__ ? "origin" : undefined,
|
||||
// Leaflet throws on an undefined template variable, so no token means an
|
||||
// empty one: the tiles 403 and the markers still draw.
|
||||
token: token ?? "",
|
||||
} as TokenTileLayerOptions)
|
||||
.addTo(map);
|
||||
|
||||
// Substituted per request, so a refreshed token needs no new layer.
|
||||
const unsubscribe = subscribeMapTilesToken((newToken) => {
|
||||
(layer.options as TokenTileLayerOptions).token = newToken;
|
||||
// Tiles that 403'd are cached as failures; only a redraw asks again.
|
||||
layer.redraw();
|
||||
});
|
||||
map.on("unload", unsubscribe);
|
||||
|
||||
return { setDarkMode: () => undefined };
|
||||
};
|
||||
|
||||
export const createBaseLayer = async (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean,
|
||||
token: string | undefined,
|
||||
// Skip the vector layer, e.g. after a permanent WebGL context loss
|
||||
rasterOnly = false
|
||||
): Promise<MapBaseLayer> => {
|
||||
if (!rasterOnly && supportsWebGL2()) {
|
||||
let vectorLayer: MapBaseLayer | undefined;
|
||||
try {
|
||||
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
|
||||
import("@maplibre/maplibre-gl-leaflet"),
|
||||
import("maplibre-gl"),
|
||||
]);
|
||||
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
|
||||
vectorLayer = await createVectorLayer(
|
||||
createLayer,
|
||||
leaflet,
|
||||
map,
|
||||
darkMode,
|
||||
token
|
||||
);
|
||||
} catch {
|
||||
// No chunk, no vector map - but still a map.
|
||||
}
|
||||
if (vectorLayer) {
|
||||
return vectorLayer;
|
||||
}
|
||||
}
|
||||
return createRasterLayer(leaflet, map, token);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
|
||||
|
||||
/** Hit target for the radius handle; comfortably above touch minimums */
|
||||
export const RESIZE_HANDLE_SIZE = 24;
|
||||
|
||||
/** The visible dot inside the hit target */
|
||||
export const RESIZE_HANDLE_DOT_SIZE = 12;
|
||||
|
||||
/** Relative radius change per arrow key press on the handle */
|
||||
export const RESIZE_KEY_STEP = 0.1;
|
||||
|
||||
export const createResizeHandleElement = (label?: string): HTMLElement => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "editable-circle-resize";
|
||||
element.tabIndex = 0;
|
||||
element.setAttribute("role", "slider");
|
||||
element.setAttribute("aria-valuemin", "1");
|
||||
// A slider needs a maximum; no zone comes near 100 km
|
||||
element.setAttribute("aria-valuemax", "100000");
|
||||
if (label) {
|
||||
element.setAttribute("aria-label", label);
|
||||
}
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "editable-circle-resize-dot";
|
||||
element.appendChild(dot);
|
||||
return element;
|
||||
};
|
||||
|
||||
/** Styles for the handles, included by ha-map for both engines */
|
||||
export const editableCircleStyles = `
|
||||
.editable-circle-center {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
border: 2px solid var(--card-background-color, #fff);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
cursor: move;
|
||||
}
|
||||
.editable-circle-resize {
|
||||
width: ${RESIZE_HANDLE_SIZE}px;
|
||||
height: ${RESIZE_HANDLE_SIZE}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.editable-circle-resize-dot {
|
||||
width: ${RESIZE_HANDLE_DOT_SIZE}px;
|
||||
height: ${RESIZE_HANDLE_DOT_SIZE}px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-background-color, #fff);
|
||||
border: 2px solid var(--primary-color);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,350 @@
|
||||
import type {
|
||||
CircleMarker,
|
||||
Control,
|
||||
Map,
|
||||
MarkerClusterGroup,
|
||||
Polyline,
|
||||
} from "leaflet";
|
||||
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
|
||||
import type { MapBaseLayer } from "../base-layer";
|
||||
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
|
||||
import { DecoratedMarker } from "../decorated_marker";
|
||||
import { isTouch } from "../../../util/is_touch";
|
||||
import type {
|
||||
MapClusterOptions,
|
||||
MapCircleOptions,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapEngineOptions,
|
||||
MapFitOptions,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
MapMarkerOptions,
|
||||
MapPath,
|
||||
} from "../map-engine";
|
||||
import { setMarkerAccessibility } from "../marker-accessibility";
|
||||
|
||||
/** A leaflet marker that knows the engine handle it was created for */
|
||||
interface HandledMarker extends DecoratedMarker {
|
||||
engineHandle?: LeafletMarkerHandle;
|
||||
}
|
||||
|
||||
interface LeafletMarkerHandle extends MapMarkerHandle {
|
||||
marker: HandledMarker;
|
||||
}
|
||||
|
||||
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
|
||||
export class LeafletMapEngine implements MapEngine {
|
||||
/** For the ha-map jsdom tests only */
|
||||
public leafletMap?: Map;
|
||||
|
||||
public Leaflet?: LeafletModuleType;
|
||||
|
||||
private _baseLayer?: MapBaseLayer;
|
||||
|
||||
private _clusterable: HandledMarker[] = [];
|
||||
|
||||
private _cluster?: MarkerClusterGroup;
|
||||
|
||||
private _clusterOptions: MapClusterOptions | null = null;
|
||||
|
||||
private _scaleControl?: Control.Scale;
|
||||
|
||||
public async init(
|
||||
container: HTMLElement,
|
||||
options: MapEngineOptions
|
||||
): Promise<void> {
|
||||
const root = container.parentNode;
|
||||
if (!root) {
|
||||
throw new Error("Cannot set up a Leaflet map on a detached element");
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
|
||||
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
|
||||
await import("leaflet.markercluster");
|
||||
|
||||
const map = Leaflet.map(container, {
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
});
|
||||
map.attributionControl.setPrefix("");
|
||||
for (const href of [
|
||||
"/static/images/leaflet/leaflet.css",
|
||||
"/static/images/leaflet/MarkerCluster.css",
|
||||
]) {
|
||||
const style = document.createElement("link");
|
||||
style.setAttribute("href", href);
|
||||
style.setAttribute("rel", "stylesheet");
|
||||
root.appendChild(style);
|
||||
}
|
||||
map.setView(options.center, options.zoom);
|
||||
|
||||
// The base layer adds itself; a vector layer may still fall back to raster
|
||||
this._baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
options.darkMode,
|
||||
options.token,
|
||||
options.rasterOnly ?? false
|
||||
);
|
||||
this.leafletMap = map;
|
||||
this.Leaflet = Leaflet;
|
||||
map.zoomControl?.setPosition(options.zoomControlPosition);
|
||||
|
||||
const { events } = options;
|
||||
if (events.click) {
|
||||
map.on("click", (ev) => {
|
||||
events.click!([ev.latlng.lat, ev.latlng.lng]);
|
||||
});
|
||||
}
|
||||
if (events.zoomStart) {
|
||||
map.on("zoomstart", () => events.zoomStart!());
|
||||
}
|
||||
if (events.moveStart) {
|
||||
map.on("movestart", () => events.moveStart!());
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.leafletMap?.remove();
|
||||
this.leafletMap = undefined;
|
||||
this.Leaflet = undefined;
|
||||
this._baseLayer = undefined;
|
||||
this._cluster = undefined;
|
||||
this._clusterable = [];
|
||||
this._scaleControl = undefined;
|
||||
}
|
||||
|
||||
public invalidateSize(): void {
|
||||
this.leafletMap?.invalidateSize({ debounceMoveend: true });
|
||||
}
|
||||
|
||||
public hasUsableSize(): boolean {
|
||||
if (!this.leafletMap) {
|
||||
return false;
|
||||
}
|
||||
const size = this.leafletMap.getSize();
|
||||
if (size.x > 0 && size.y > 0) {
|
||||
return true;
|
||||
}
|
||||
const container = this.leafletMap.getContainer();
|
||||
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
||||
// The container was laid out since Leaflet last measured it
|
||||
this.leafletMap.invalidateSize(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public setDarkMode(darkMode: boolean): void {
|
||||
this._baseLayer?.setDarkMode(darkMode);
|
||||
}
|
||||
|
||||
public setZoomControlPosition(position: MapControlPosition): void {
|
||||
this.leafletMap?.zoomControl?.setPosition(position);
|
||||
}
|
||||
|
||||
public setScaleRuler(options: { metric: boolean } | null): void {
|
||||
if (this._scaleControl) {
|
||||
this.leafletMap?.removeControl(this._scaleControl);
|
||||
this._scaleControl = undefined;
|
||||
}
|
||||
if (!options || !this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
this._scaleControl = this.Leaflet.control.scale({
|
||||
position: "bottomleft",
|
||||
metric: options.metric,
|
||||
imperial: !options.metric,
|
||||
});
|
||||
this._scaleControl.addTo(this.leafletMap!);
|
||||
}
|
||||
|
||||
public setView(center: MapLatLng, zoom?: number): void {
|
||||
this.leafletMap?.setView(center, zoom);
|
||||
}
|
||||
|
||||
public setZoom(zoom: number): void {
|
||||
this.leafletMap?.setZoom(zoom);
|
||||
}
|
||||
|
||||
private _getZoom(): number {
|
||||
return this.leafletMap?.getZoom() ?? 0;
|
||||
}
|
||||
|
||||
private _project(location: MapLatLng): { x: number; y: number } {
|
||||
const point = this.leafletMap!.project(location, this._getZoom());
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
|
||||
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
|
||||
if (!this.leafletMap || !this.Leaflet || !points.length) {
|
||||
return;
|
||||
}
|
||||
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
|
||||
this.leafletMap.fitBounds(bounds, {
|
||||
maxZoom: options?.maxZoom,
|
||||
animate: options?.animate,
|
||||
});
|
||||
}
|
||||
|
||||
public panTo(location: MapLatLng): void {
|
||||
this.leafletMap?.panTo(location);
|
||||
}
|
||||
|
||||
public containsLocation(location: MapLatLng): boolean {
|
||||
return this.leafletMap?.getBounds().contains(location) ?? false;
|
||||
}
|
||||
|
||||
public addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapMarkerOptions
|
||||
): MapMarkerHandle {
|
||||
const decoration = options.decoration
|
||||
? this.Leaflet!.circle(location, {
|
||||
interactive: false,
|
||||
color: options.decoration.color,
|
||||
radius: options.decoration.radius,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Leaflet's keyboard support focuses its own wrapper, where the element's
|
||||
// activation handlers never hear a key; the element itself takes focus
|
||||
const interactive = options.interactive ?? true;
|
||||
if (interactive) {
|
||||
element.tabIndex = 0;
|
||||
}
|
||||
setMarkerAccessibility(element, options.title, interactive);
|
||||
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
|
||||
icon: this.Leaflet!.divIcon({
|
||||
html: element,
|
||||
iconSize: options.size,
|
||||
iconAnchor: options.anchor,
|
||||
className: "",
|
||||
}),
|
||||
interactive,
|
||||
keyboard: false,
|
||||
title: options.title,
|
||||
});
|
||||
|
||||
const handle: LeafletMarkerHandle = {
|
||||
marker,
|
||||
location,
|
||||
clusterData: options.clusterData,
|
||||
remove: () => {
|
||||
this._cluster?.removeLayer(marker);
|
||||
marker.remove();
|
||||
const index = this._clusterable.indexOf(marker);
|
||||
if (index !== -1) {
|
||||
this._clusterable.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
marker.engineHandle = handle;
|
||||
|
||||
if (options.cluster) {
|
||||
// Placed on the map by the next setClustering call
|
||||
this._clusterable.push(marker);
|
||||
} else {
|
||||
marker.addTo(this.leafletMap!);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
public addCircle(
|
||||
center: MapLatLng,
|
||||
options: MapCircleOptions
|
||||
): MapItemHandle {
|
||||
const circle = this.Leaflet!.circle(center, {
|
||||
interactive: false,
|
||||
color: options.color,
|
||||
radius: options.radius,
|
||||
}).addTo(this.leafletMap!);
|
||||
return { remove: () => circle.remove() };
|
||||
}
|
||||
|
||||
public addPath(path: MapPath): MapItemHandle {
|
||||
const items: (Polyline | CircleMarker)[] = [];
|
||||
for (const segment of path.segments) {
|
||||
items.push(
|
||||
this.Leaflet!.polyline(segment.points, {
|
||||
color: path.color,
|
||||
opacity: segment.opacity,
|
||||
interactive: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const pathMarker of path.markers) {
|
||||
items.push(
|
||||
this.Leaflet!.circleMarker(pathMarker.location, {
|
||||
radius: isTouch ? 8 : 3,
|
||||
color: path.color,
|
||||
opacity: pathMarker.opacity,
|
||||
fillOpacity: pathMarker.opacity,
|
||||
interactive: true,
|
||||
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
|
||||
);
|
||||
}
|
||||
items.forEach((item) => item.addTo(this.leafletMap!));
|
||||
return { remove: () => items.forEach((item) => item.remove()) };
|
||||
}
|
||||
|
||||
public setClustering(options: MapClusterOptions | null): void {
|
||||
if (this._cluster) {
|
||||
this._cluster.remove();
|
||||
this._cluster = undefined;
|
||||
}
|
||||
this._clusterOptions = options;
|
||||
if (!this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
if (!options) {
|
||||
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
|
||||
return;
|
||||
}
|
||||
// markercluster groups by proximity only; groupKey is not supported here
|
||||
this._cluster = this.Leaflet.markerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
removeOutsideVisibleBounds: false,
|
||||
maxClusterRadius: options.radius,
|
||||
iconCreateFunction: (cluster) => {
|
||||
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
|
||||
(marker) => marker.engineHandle!
|
||||
);
|
||||
const latLng = cluster.getLatLng();
|
||||
const icon = this._clusterOptions!.iconBuilder(members, [
|
||||
latLng.lat,
|
||||
latLng.lng,
|
||||
]);
|
||||
// The element fills the divIcon wrapper, which gets the size
|
||||
icon.element.style.width = `${icon.size[0]}px`;
|
||||
icon.element.style.height = `${icon.size[1]}px`;
|
||||
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
|
||||
let anchor = icon.anchor;
|
||||
if (icon.location) {
|
||||
const clusterPoint = this._project([latLng.lat, latLng.lng]);
|
||||
const targetPoint = this._project(icon.location);
|
||||
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
|
||||
anchor = [
|
||||
base[0] - (targetPoint.x - clusterPoint.x),
|
||||
base[1] - (targetPoint.y - clusterPoint.y),
|
||||
];
|
||||
}
|
||||
return this.Leaflet!.divIcon({
|
||||
html: icon.element,
|
||||
iconSize: icon.size,
|
||||
iconAnchor: anchor,
|
||||
className: "",
|
||||
});
|
||||
},
|
||||
});
|
||||
this._cluster.addLayers(this._clusterable);
|
||||
this.leafletMap!.addLayer(this._cluster!);
|
||||
}
|
||||
|
||||
public refreshClusters(): void {
|
||||
this._cluster?.refreshClusters();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
|
||||
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
|
||||
* contract; new capabilities go on MapLibre only, as optional members like
|
||||
* `editing`.
|
||||
*
|
||||
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
|
||||
*/
|
||||
|
||||
export type MapLatLng = [latitude: number, longitude: number];
|
||||
|
||||
export type MapControlPosition =
|
||||
"topleft" | "topright" | "bottomleft" | "bottomright";
|
||||
|
||||
export interface MapEngineEvents {
|
||||
/** Click on the map surface, not on a marker */
|
||||
click(location: MapLatLng): void;
|
||||
/** Zoom is starting, programmatic or not */
|
||||
zoomStart(): void;
|
||||
/** The map starts moving, programmatic or not */
|
||||
moveStart(): void;
|
||||
/** The engine can no longer render; the host switches to the fallback */
|
||||
fatal(): void;
|
||||
}
|
||||
|
||||
export interface MapEngineOptions {
|
||||
center: MapLatLng;
|
||||
zoom: number;
|
||||
darkMode: boolean;
|
||||
/** Token for core's tile proxy */
|
||||
token?: string;
|
||||
zoomControlPosition: MapControlPosition;
|
||||
/** Render without WebGL after a permanent context loss; WebGL engines reject init */
|
||||
rasterOnly?: boolean;
|
||||
events: Partial<MapEngineEvents>;
|
||||
}
|
||||
|
||||
export interface MapFitOptions {
|
||||
/** Do not zoom in beyond this level even if the bounds would allow it */
|
||||
maxZoom?: number;
|
||||
/** Relative padding around the bounds, e.g. 0.5 grows them by 50% */
|
||||
pad?: number;
|
||||
/** Ease the camera to the bounds instead of jumping; defaults to true */
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export interface MapMarkerOptions {
|
||||
/** Rendered size of the element in pixels */
|
||||
size: [width: number, height: number];
|
||||
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
|
||||
anchor?: [x: number, y: number];
|
||||
/** Takes pointer input and keyboard focus; defaults to true */
|
||||
interactive?: boolean;
|
||||
/** Accessible name */
|
||||
title?: string;
|
||||
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
|
||||
decoration?: MapCircleOptions;
|
||||
/** Cluster this marker; it appears once setClustering is called */
|
||||
cluster?: boolean;
|
||||
/** Caller data handed back to the cluster icon builder */
|
||||
clusterData?: unknown;
|
||||
}
|
||||
|
||||
export interface MapCircleOptions {
|
||||
/** Radius in meters */
|
||||
radius: number;
|
||||
/** Stroke color; the fill is derived from it, translucent */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface MapPathSegment {
|
||||
points: MapLatLng[];
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface MapPathMarker {
|
||||
location: MapLatLng;
|
||||
opacity?: number;
|
||||
/** Tooltip/popup HTML shown on hover; caller is responsible for escaping */
|
||||
tooltipHtml: string;
|
||||
}
|
||||
|
||||
export interface MapPath {
|
||||
color: string;
|
||||
segments: MapPathSegment[];
|
||||
markers: MapPathMarker[];
|
||||
}
|
||||
|
||||
/** Handle to anything placed on the map; remove() must be idempotent */
|
||||
export interface MapItemHandle {
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
export interface MapMarkerHandle extends MapItemHandle {
|
||||
readonly location: MapLatLng;
|
||||
readonly clusterData?: unknown;
|
||||
}
|
||||
|
||||
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
|
||||
onDragEnd?(location: MapLatLng): void;
|
||||
}
|
||||
|
||||
export interface MapEditableCircleOptions {
|
||||
/** Radius in meters */
|
||||
radius: number;
|
||||
/** Stroke color; the fill is derived from it, translucent */
|
||||
color: string;
|
||||
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
|
||||
centerElement?: HTMLElement;
|
||||
centerSize?: [width: number, height: number];
|
||||
title?: string;
|
||||
/** The center can be dragged */
|
||||
moveable?: boolean;
|
||||
/** A handle on the edge can be dragged to change the radius */
|
||||
resizable?: boolean;
|
||||
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
|
||||
resizeLabel?: string;
|
||||
onMove?(center: MapLatLng): void;
|
||||
onResize?(radius: number): void;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
export interface MapEditingSupport {
|
||||
/** Place a draggable HTML element marker */
|
||||
addDraggableMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapDraggableMarkerOptions
|
||||
): MapEditableMarkerHandle;
|
||||
|
||||
/** Draw a circle whose center and radius can be dragged */
|
||||
addEditableCircle(
|
||||
center: MapLatLng,
|
||||
options: MapEditableCircleOptions
|
||||
): MapEditableCircleHandle;
|
||||
}
|
||||
|
||||
/** A circle with drag handles for its center and radius */
|
||||
export interface MapEditableCircleHandle extends MapItemHandle {
|
||||
readonly center: MapLatLng;
|
||||
readonly radius: number;
|
||||
/** Move and resize without recreating (no-op mid-drag) */
|
||||
update(center: MapLatLng, radius: number): void;
|
||||
}
|
||||
|
||||
export interface MapEditableMarkerHandle extends MapMarkerHandle {
|
||||
/** Move without recreating (no-op mid-drag) */
|
||||
setLocation(location: MapLatLng): void;
|
||||
}
|
||||
|
||||
export interface MapClusterIcon {
|
||||
element: HTMLElement;
|
||||
size: [width: number, height: number];
|
||||
/** Like MapMarkerOptions.anchor; defaults to the element's center */
|
||||
anchor?: [x: number, y: number];
|
||||
/** Show the icon here instead of at the cluster, e.g. attached to a zone */
|
||||
location?: MapLatLng;
|
||||
}
|
||||
|
||||
export interface MapClusterOptions {
|
||||
/** Cluster markers closer than this many screen pixels */
|
||||
radius: number;
|
||||
/**
|
||||
* Markers sharing a key (e.g. their zone) form one group while they span
|
||||
* at most groupRadius pixels; beyond that, and without a key, they cluster
|
||||
* by proximity.
|
||||
*/
|
||||
groupKey?(marker: MapMarkerHandle): string | undefined;
|
||||
groupRadius?: number;
|
||||
/** Builds a cluster's element; called when its members change and on refreshClusters() */
|
||||
iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon;
|
||||
}
|
||||
|
||||
export interface MapEngine {
|
||||
/** Create the map in the container; call once */
|
||||
init(container: HTMLElement, options: MapEngineOptions): Promise<void>;
|
||||
|
||||
/** Tear down the map and release its resources (DOM, workers, WebGL) */
|
||||
destroy(): void;
|
||||
|
||||
/** Re-measure the container after a size change */
|
||||
invalidateSize(): void;
|
||||
|
||||
/** Whether the map has a non-zero size, re-measuring if needed */
|
||||
hasUsableSize(): boolean;
|
||||
|
||||
setDarkMode(darkMode: boolean): void;
|
||||
|
||||
setZoomControlPosition(position: MapControlPosition): void;
|
||||
|
||||
/** Show a scale ruler (bottom start); null hides it */
|
||||
setScaleRuler(options: { metric: boolean } | null): void;
|
||||
|
||||
setView(center: MapLatLng, zoom?: number): void;
|
||||
|
||||
setZoom(zoom: number): void;
|
||||
|
||||
/** Fit the given points into view; a single point centers on it */
|
||||
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
|
||||
|
||||
/** Pan to the location, keeping the zoom */
|
||||
panTo(location: MapLatLng): void;
|
||||
|
||||
/** Whether the location is inside the current viewport */
|
||||
containsLocation(location: MapLatLng): boolean;
|
||||
|
||||
/** Place a caller-owned element on the map */
|
||||
addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapMarkerOptions
|
||||
): MapMarkerHandle;
|
||||
|
||||
/** Draw a meter-radius circle (zone radius) */
|
||||
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
|
||||
|
||||
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
|
||||
editing?: MapEditingSupport;
|
||||
|
||||
/** Draw one history trail (points with tooltips, connecting segments) */
|
||||
addPath(path: MapPath): MapItemHandle;
|
||||
|
||||
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
|
||||
setClustering(options: MapClusterOptions | null): void;
|
||||
|
||||
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
|
||||
refreshClusters(): void;
|
||||
}
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
|
||||
/** Great-circle distance in meters */
|
||||
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(b[0] - a[0]);
|
||||
const dLng = toRad(b[1] - a[1]);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
/** The point the given distance due east of center, e.g. for a resize handle */
|
||||
export const pointEastOf = (
|
||||
center: MapLatLng,
|
||||
distanceInMeters: number
|
||||
): MapLatLng => {
|
||||
const lngOffset =
|
||||
(distanceInMeters /
|
||||
(EARTH_RADIUS * Math.cos((center[0] * Math.PI) / 180))) *
|
||||
(180 / Math.PI);
|
||||
return [center[0], center[1] + lngOffset];
|
||||
};
|
||||
|
||||
/** Bounding box corners of a circle, for fitting a radius into view */
|
||||
export const circleBoundsPoints = (
|
||||
center: MapLatLng,
|
||||
radiusMeters: number
|
||||
): MapLatLng[] => {
|
||||
const latOffset = radiusMeters / 111320;
|
||||
const lngOffset =
|
||||
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
|
||||
return [
|
||||
[center[0] - latOffset, center[1] - lngOffset],
|
||||
[center[0] + latOffset, center[1] + lngOffset],
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Marker elements are focusable buttons on both engines, so the caller's
|
||||
* element is the keyboard target and needs a name and a role. MapLibre would
|
||||
* otherwise label it "Map marker".
|
||||
*/
|
||||
export const setMarkerAccessibility = (
|
||||
element: HTMLElement,
|
||||
title: string | undefined,
|
||||
interactive: boolean
|
||||
): void => {
|
||||
if (title && !element.hasAttribute("aria-label")) {
|
||||
element.setAttribute("aria-label", title);
|
||||
}
|
||||
if (!element.hasAttribute("role")) {
|
||||
if (interactive) {
|
||||
element.setAttribute("role", "button");
|
||||
} else if (title) {
|
||||
element.setAttribute("role", "img");
|
||||
} else {
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
};
|
||||
+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;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ export type LocalizeKeys =
|
||||
| `ui.dialogs.unsupported.reasons.${string}`
|
||||
| `ui.panel.config.${string}.${"caption" | "description"}`
|
||||
| `ui.panel.config.dashboard.${string}`
|
||||
| `ui.panel.config.mqtt.${string}`
|
||||
| `ui.panel.config.storage.segments.${string}`
|
||||
| `ui.panel.config.zha.${string}`
|
||||
| `ui.panel.config.zwave_js.${string}`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { deepEqual } from "../util/deep-equal";
|
||||
import {
|
||||
createQueryString,
|
||||
decodeQueryParams,
|
||||
@@ -35,6 +36,15 @@ export const historyLogbookTargetFromQueryParams = (
|
||||
): HassServiceTarget | undefined =>
|
||||
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
|
||||
|
||||
export const historyLogbookTargetsEqual = (
|
||||
a: HassServiceTarget,
|
||||
b: HassServiceTarget
|
||||
): boolean =>
|
||||
deepEqual(
|
||||
queryParamsFromServiceTarget(a, historyLogbookTargetParamKeys),
|
||||
queryParamsFromServiceTarget(b, historyLogbookTargetParamKeys)
|
||||
);
|
||||
|
||||
export const createHistoryLogbookUrl = (
|
||||
path: string,
|
||||
target: HassServiceTarget,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
isMoreInfoView,
|
||||
type MoreInfoView,
|
||||
} from "../../dialogs/more-info/more-info-view";
|
||||
import type { SearchParamsSource } from "./query-params";
|
||||
|
||||
const ENTITY_ID_PARAM = "more-info-entity-id";
|
||||
const VIEW_PARAM = "more-info-view";
|
||||
|
||||
export interface MoreInfoUrlData {
|
||||
entityId?: string;
|
||||
view?: MoreInfoView;
|
||||
}
|
||||
|
||||
export interface CreateMoreInfoUrlData {
|
||||
entityId: string;
|
||||
view: MoreInfoView;
|
||||
}
|
||||
|
||||
export const decodeMoreInfoUrl = (
|
||||
search: SearchParamsSource
|
||||
): MoreInfoUrlData => {
|
||||
const params =
|
||||
typeof search === "string"
|
||||
? new URLSearchParams(search)
|
||||
: search instanceof URLSearchParams
|
||||
? search
|
||||
: new URLSearchParams(search);
|
||||
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
|
||||
const view = params.get(VIEW_PARAM) || undefined;
|
||||
|
||||
return {
|
||||
entityId,
|
||||
view: isMoreInfoView(view) ? view : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMoreInfoUrl = (
|
||||
base: string,
|
||||
data: CreateMoreInfoUrlData
|
||||
): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
|
||||
url.searchParams.set(VIEW_PARAM, data.view);
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
|
||||
export const removeMoreInfoUrl = (base: string): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.delete(ENTITY_ID_PARAM);
|
||||
url.searchParams.delete(VIEW_PARAM);
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
export const startMediaProgressInterval = (
|
||||
interval: number | undefined,
|
||||
callback: () => void,
|
||||
intervalMs = 1000
|
||||
): number => {
|
||||
if (interval) {
|
||||
return interval;
|
||||
}
|
||||
return window.setInterval(callback, intervalMs);
|
||||
};
|
||||
|
||||
export const stopMediaProgressInterval = (
|
||||
interval: number | undefined
|
||||
): number | undefined => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import deepFreeze from "deep-freeze";
|
||||
|
||||
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
|
||||
|
||||
export const shareInFlightRequest = <T>(
|
||||
owner: object,
|
||||
key: string,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
let requests = inFlightRequests.get(owner);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
inFlightRequests.set(owner, requests);
|
||||
}
|
||||
|
||||
const ownerRequests = requests;
|
||||
const existing = ownerRequests.get(key);
|
||||
if (existing) {
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = fetcher()
|
||||
.then((result) => deepFreeze(result) as T)
|
||||
.finally(() => {
|
||||
if (ownerRequests.get(key) !== request) {
|
||||
return;
|
||||
}
|
||||
|
||||
ownerRequests.delete(key);
|
||||
if (ownerRequests.size === 0) {
|
||||
inFlightRequests.delete(owner);
|
||||
}
|
||||
});
|
||||
|
||||
ownerRequests.set(key, request);
|
||||
return request;
|
||||
};
|
||||
@@ -55,17 +55,20 @@ export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
|
||||
}
|
||||
|
||||
const resultPromise = func(hass, ...args);
|
||||
anyHass[cacheKey] = resultPromise;
|
||||
const cachePromise = resultPromise.then((result) => ({
|
||||
result,
|
||||
cacheKey: generateCacheKey?.(hass, result),
|
||||
}));
|
||||
anyHass[cacheKey] = cachePromise;
|
||||
|
||||
resultPromise.then(
|
||||
cachePromise.then(
|
||||
// When successful, set timer to clear cache
|
||||
(result) => {
|
||||
anyHass[cacheKey] = {
|
||||
result,
|
||||
cacheKey: generateCacheKey?.(hass, result),
|
||||
};
|
||||
anyHass[cacheKey] = result;
|
||||
setTimeout(() => {
|
||||
anyHass[cacheKey] = undefined;
|
||||
if (anyHass[cacheKey] === result) {
|
||||
anyHass[cacheKey] = undefined;
|
||||
}
|
||||
}, cacheTime);
|
||||
},
|
||||
// On failure, clear cache right away
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { formatDateTime } from "../../common/datetime/format_date_time";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import type {
|
||||
HaECSeries,
|
||||
HaECSeriesItem,
|
||||
} from "../../resources/echarts/echarts";
|
||||
|
||||
export interface ChartSonification {
|
||||
update: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
// Series types the Chart2Music ECharts extension can turn into data points. Our
|
||||
// other series (custom timelines, sankey, network graphs) have no equivalent, and
|
||||
// the extension refuses the whole chart if a single series is unsupported.
|
||||
const SONIFIABLE_SERIES_TYPES = new Set(["bar", "line", "pie", "scatter"]);
|
||||
|
||||
// Languages shipped by chart2music. Anything else falls back to its English.
|
||||
const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
|
||||
|
||||
// Fewer than this and there is nothing to walk between, so a focus stop would
|
||||
// lead nowhere.
|
||||
const MIN_NAVIGABLE_POINTS = 2;
|
||||
|
||||
const itemValues = (raw: unknown): unknown[] | null => {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (raw && typeof raw === "object") {
|
||||
const { value } = raw as { value?: unknown };
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// ECharts spells empty values and numbers as strings too, and neither names a
|
||||
// category.
|
||||
const NON_CATEGORY_STRINGS = new Set(["-", "NaN", "null", "undefined"]);
|
||||
|
||||
const isCategoryKey = (value: unknown): boolean =>
|
||||
typeof value === "string" &&
|
||||
!NON_CATEGORY_STRINGS.has(value) &&
|
||||
Number.isNaN(Number(value));
|
||||
|
||||
// A chart with the value axis on x, like the energy device charts, encodes its
|
||||
// items value-first: [amount, "sensor.foo"]. The extension only reads a series
|
||||
// that way when every item has that shape, so mirror the same gate.
|
||||
const isValueFirstSeries = (data: readonly unknown[]): boolean =>
|
||||
data.length > 0 &&
|
||||
data.every((raw) => {
|
||||
const values = itemValues(raw);
|
||||
return (
|
||||
!!values && typeof values[0] === "number" && isCategoryKey(values[1])
|
||||
);
|
||||
});
|
||||
|
||||
// Mirrors the extension's own reading of a point: it takes `value` as [x, y]
|
||||
// (or [y, category] in a value-first series) and drops anything whose y is not
|
||||
// a real number, which rejects gap-only series. Counts no further than `limit`
|
||||
// so this stays cheap on charts with many points.
|
||||
const countNumericPoints = (data: unknown, limit: number): number => {
|
||||
if (!Array.isArray(data)) {
|
||||
return 0;
|
||||
}
|
||||
const valueFirst = isValueFirstSeries(data);
|
||||
let found = 0;
|
||||
for (const raw of data) {
|
||||
let y: unknown = raw;
|
||||
const values = itemValues(raw);
|
||||
if (values) {
|
||||
y = valueFirst ? values[0] : values.length > 1 ? values[1] : values[0];
|
||||
} else if (raw && typeof raw === "object") {
|
||||
y = (raw as { value?: unknown }).value;
|
||||
}
|
||||
if (typeof y === "number" && !Number.isNaN(y)) {
|
||||
found += 1;
|
||||
if (found >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const countNavigablePoints = (
|
||||
series: readonly ({ data?: unknown } | undefined)[]
|
||||
): number => {
|
||||
let total = 0;
|
||||
for (const s of series) {
|
||||
total += countNumericPoints(s?.data, MIN_NAVIGABLE_POINTS - total);
|
||||
if (total >= MIN_NAVIGABLE_POINTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
export const canSonifyChart = (
|
||||
data: HaECSeries,
|
||||
// Legend-hidden series reach ECharts with their data stripped, so they cannot
|
||||
// be sonified either.
|
||||
hiddenDatasets?: ReadonlySet<string>
|
||||
): boolean => {
|
||||
const series = ensureArray(data);
|
||||
const visible = hiddenDatasets?.size
|
||||
? series.filter((s) => !hiddenDatasets.has(String(s.id ?? s.name)))
|
||||
: series;
|
||||
return (
|
||||
// Cards commonly push empty placeholder series, so judge the chart by the
|
||||
// points the extension can actually read — but every type has to be
|
||||
// convertible too.
|
||||
countNavigablePoints(visible) >= MIN_NAVIGABLE_POINTS &&
|
||||
series.every((s) => SONIFIABLE_SERIES_TYPES.has(s.type as string))
|
||||
);
|
||||
};
|
||||
|
||||
interface SonifyChartOptions {
|
||||
cc: HTMLElement;
|
||||
localize: LocalizeFunc;
|
||||
locale: FrontendLocaleData;
|
||||
config: HassConfig;
|
||||
// Maps a category key or item name to what should be announced for it, so
|
||||
// cards that key their data on ids (like the energy device charts) can have
|
||||
// the display names read out instead. Returning undefined keeps the original.
|
||||
formatLabel?: (label: string) => string | undefined;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
// Chart2Music appends its help and options dialogs straight to document.body, so
|
||||
// they can only be themed from a document-level stylesheet.
|
||||
let stylesAppended = false;
|
||||
const appendSonificationStyles = () => {
|
||||
if (stylesAppended) {
|
||||
return;
|
||||
}
|
||||
stylesAppended = true;
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
dialog.chart2music-dialog {
|
||||
box-sizing: border-box;
|
||||
max-width: min(600px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
padding: var(--ha-space-6);
|
||||
border: none;
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
box-shadow: var(--ha-box-shadow-l);
|
||||
}
|
||||
dialog.chart2music-dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
dialog.chart2music-dialog h1 {
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
margin-block: 0 var(--ha-space-4);
|
||||
padding-inline-end: var(--ha-space-8);
|
||||
}
|
||||
dialog.chart2music-dialog table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
dialog.chart2music-dialog th,
|
||||
dialog.chart2music-dialog td {
|
||||
text-align: start;
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
dialog.chart2music-dialog a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
dialog.chart2music-dialog > button {
|
||||
/* The extension inlines "right", which does not mirror in RTL, and inline
|
||||
styles can only be beaten with !important. */
|
||||
inset-inline-end: var(--ha-space-4) !important;
|
||||
inset-inline-start: auto !important;
|
||||
top: var(--ha-space-4);
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
background-color: transparent;
|
||||
color: var(--primary-text-color);
|
||||
font: inherit;
|
||||
}
|
||||
`;
|
||||
document.head.append(style);
|
||||
};
|
||||
|
||||
// Rebuilds the labels the extension would announce — the category axis's data,
|
||||
// or the item names on pies, which ignore whatever vestigial axes the chart
|
||||
// options carry — with each one run through the card's formatter. Returns
|
||||
// undefined when there is nothing to reword, so the extension's own labels
|
||||
// stay untouched.
|
||||
const buildValueLabels = (
|
||||
categoryAxis: { type?: string; data?: unknown } | undefined,
|
||||
firstSeries: { type?: string; data?: unknown } | undefined,
|
||||
formatLabel?: (label: string) => string | undefined
|
||||
): string[] | undefined => {
|
||||
if (!formatLabel) {
|
||||
return undefined;
|
||||
}
|
||||
const axisData =
|
||||
categoryAxis?.type === "category" &&
|
||||
Array.isArray(categoryAxis.data) &&
|
||||
categoryAxis.data.length
|
||||
? categoryAxis.data
|
||||
: undefined;
|
||||
const labels = axisData
|
||||
? axisData.map((entry) =>
|
||||
entry && typeof entry === "object"
|
||||
? String((entry as { value?: unknown }).value ?? "")
|
||||
: String(entry ?? "")
|
||||
)
|
||||
: firstSeries?.type === "pie" && Array.isArray(firstSeries.data)
|
||||
? firstSeries.data.map((raw) => {
|
||||
const name = (raw as { name?: unknown } | null)?.name;
|
||||
if (typeof name === "string") {
|
||||
return name;
|
||||
}
|
||||
const values = itemValues(raw);
|
||||
return values && isCategoryKey(values[1]) ? String(values[1]) : "";
|
||||
})
|
||||
: undefined;
|
||||
if (!labels?.length || labels.every((label) => !label)) {
|
||||
return undefined;
|
||||
}
|
||||
return labels.map((label) => formatLabel(label) ?? label);
|
||||
};
|
||||
|
||||
export const sonifyChart = async (
|
||||
chart: EChartsType,
|
||||
options: SonifyChartOptions
|
||||
): Promise<ChartSonification | null> => {
|
||||
const { connect } = await import("echarts-extension-chart2music");
|
||||
const { localize, locale, config } = options;
|
||||
appendSonificationStyles();
|
||||
|
||||
// ECharts nulls its model on dispose, and the instance can be disposed while
|
||||
// the chunk is in flight.
|
||||
const chartOptions = chart.getOption() as ReturnType<
|
||||
EChartsType["getOption"]
|
||||
> | null;
|
||||
if (!chartOptions) {
|
||||
return null;
|
||||
}
|
||||
const xAxis = ensureArray(chartOptions.xAxis)?.[0] as XAXisOption | undefined;
|
||||
const yAxis = ensureArray(chartOptions.yAxis)?.[0] as YAXisOption | undefined;
|
||||
|
||||
// Chart2Music throws while validating a group with no points, which is what
|
||||
// placeholder, legend-hidden and all-null series turn into, so only offer it
|
||||
// the series carrying points it can read.
|
||||
const allSeries = ensureArray(chartOptions.series) as (
|
||||
HaECSeriesItem | undefined
|
||||
)[];
|
||||
const readable = allSeries.filter((s) => countNumericPoints(s?.data, 1));
|
||||
// A single point is not navigable, so it does not earn a focus stop either.
|
||||
if (countNavigablePoints(readable) < MIN_NAVIGABLE_POINTS) {
|
||||
return null;
|
||||
}
|
||||
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
|
||||
|
||||
// Chart2Music always reads out an axis label, and the extension picks the wrong
|
||||
// axis to name when there is no category axis, so label both explicitly. On a
|
||||
// horizontal chart the announced x is the category from the y axis and the
|
||||
// announced y is the value from the x axis, so the sources swap.
|
||||
const isTimeAxis = xAxis?.type === "time";
|
||||
const isHorizontal = xAxis?.type === "value" && yAxis?.type === "category";
|
||||
const valueLabels = buildValueLabels(
|
||||
isHorizontal ? yAxis : xAxis,
|
||||
readable[0],
|
||||
options.formatLabel
|
||||
);
|
||||
const x = {
|
||||
label:
|
||||
(isHorizontal ? yAxis?.name : xAxis?.name) ||
|
||||
localize(
|
||||
isTimeAxis
|
||||
? "ui.components.history_charts.time"
|
||||
: "ui.components.history_charts.category"
|
||||
),
|
||||
...(valueLabels ? { valueLabels } : {}),
|
||||
// Time series carry raw timestamps, which would otherwise be announced as
|
||||
// epoch milliseconds.
|
||||
format: isTimeAxis
|
||||
? (value: number) => formatDateTime(new Date(value), locale, config)
|
||||
: undefined,
|
||||
};
|
||||
const y = {
|
||||
label:
|
||||
(isHorizontal ? xAxis?.name : yAxis?.name) ||
|
||||
localize("ui.components.history_charts.value"),
|
||||
};
|
||||
|
||||
let connection: ReturnType<typeof connect>;
|
||||
try {
|
||||
connection = connect(chart, {
|
||||
cc: options.cc,
|
||||
seriesIndex,
|
||||
title: localize("ui.components.history_charts.chart"),
|
||||
lang: SONIFICATION_LANGUAGES.has(locale.language)
|
||||
? locale.language
|
||||
: "en",
|
||||
errorCallback: options.onError,
|
||||
axes: { x, y },
|
||||
});
|
||||
} catch (err) {
|
||||
options.onError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
if (!connection) {
|
||||
return null;
|
||||
}
|
||||
// Chart2Music bails out silently on mobile user agents, returning an instance
|
||||
// that never wired anything up. Turning the caption container into a live
|
||||
// region is the last thing it does, so use that as the "really connected" test
|
||||
// rather than leaving a focus stop that does nothing.
|
||||
if (!options.cc.hasAttribute("aria-live")) {
|
||||
connection.dispose();
|
||||
return null;
|
||||
}
|
||||
const connected = connection;
|
||||
|
||||
// The extension re-reads the chart from ECharts' own "finished" event. Run that
|
||||
// through a guard of our own so a conversion failure cannot escape into
|
||||
// ECharts' event dispatch and leave the chart half-rendered.
|
||||
const update = () => {
|
||||
try {
|
||||
connected.update();
|
||||
} catch (_err) {
|
||||
// Keep whatever Chart2Music last read successfully.
|
||||
}
|
||||
};
|
||||
chart.off("finished", connected.update);
|
||||
chart.on("finished", update);
|
||||
|
||||
return {
|
||||
update,
|
||||
dispose: () => {
|
||||
chart.off("finished", update);
|
||||
connected.dispose();
|
||||
},
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user