mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-16 05:49:19 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b10a9d56 | ||
|
|
af530c1d26 | ||
|
|
144c263141 | ||
|
|
c5b8a0ca2f | ||
|
|
9bced2c02c | ||
|
|
af8eb27821 | ||
|
|
4b5a04d81c | ||
|
|
0379245d00 | ||
|
|
f9660f6fda | ||
|
|
17041e1e74 | ||
|
|
17fb98d3ea | ||
|
|
56c73c8558 | ||
|
|
fa29ceb11e | ||
|
|
fb389baaa1 |
@@ -1,18 +1,12 @@
|
||||
---
|
||||
name: ha-frontend-review
|
||||
description: Home Assistant frontend PR and review guidance, including implementation quality, existing review feedback, established design patterns, and the authority of UI/UX evidence. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
|
||||
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
|
||||
---
|
||||
|
||||
# HA Frontend Review
|
||||
|
||||
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
|
||||
|
||||
## Review Preparation
|
||||
|
||||
1. Establish the review scope from the diff and the behavior changed by it.
|
||||
2. Before identifying findings, use the [project skill catalogue](../../../AGENTS.md#project-skills) to load every matching companion's `SKILL.md`. Follow applicable companion references in those skills as well; this review skill supplies the workflow, and companions supply the domain-specific criteria.
|
||||
3. Apply all loaded guidance within that scope. Report only problems introduced or worsened by the changeset, anchored to changed lines. Read surrounding code to understand affected behavior, not to generate unrelated cleanup or migration requests.
|
||||
|
||||
## Pull Request Body
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
|
||||
@@ -28,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.
|
||||
- Each test added by the change protects real logic, not the look of a component.
|
||||
- Tests are added or updated for new data processing and utilities where applicable.
|
||||
- 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.
|
||||
@@ -98,27 +92,9 @@ Configuration and props:
|
||||
- Keep APIs extensible without adding speculative abstractions.
|
||||
- Validate configuration before applying changes.
|
||||
|
||||
## UI/UX Evidence
|
||||
|
||||
For user-facing changes, establish the existing design context as part of frontend review:
|
||||
|
||||
- Treat an applicable gallery specification as the authoritative repository source for the documented component or interaction. Inspect its written guidance and demos when present.
|
||||
- Note relevant production designs, approved designs, and shared `ha-*` components so the implementation can also be compared with established frontend behaviour.
|
||||
- Require the applicable gallery documentation or demo to change when the implementation intentionally changes behaviour documented there.
|
||||
- Prefer an established input layout or appropriate shared component over a raw input or ad hoc control.
|
||||
- Treat explicit UI/UX approval, current repository guidance, and direct human or workflow instructions as stronger evidence than inference.
|
||||
- Treat linked tasks as evidence of the problem and stated requirements, not automatic UI/UX approval.
|
||||
- When the direction remains uncertain, search frontend pull requests that carry or previously carried **Needs UX**. Inspect their label history, comments, and reviews rather than relying on the current label alone.
|
||||
- Judge relevant feedback by its content and surrounding discussion. When GitHub provides `author_association`, prioritise feedback marked `MEMBER`.
|
||||
- Prefer recent feedback about similar interactions or components. Label removal can show that a workflow gate moved on, but does not prove approval without the surrounding discussion.
|
||||
- Summarise the applicable guidance instead of maintaining a reviewer list or treating one historical decision as a permanent rule.
|
||||
|
||||
## Review Flow
|
||||
|
||||
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
|
||||
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
|
||||
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
|
||||
|
||||
@@ -49,13 +49,12 @@ 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.
|
||||
|
||||
## When To Add Tests
|
||||
## Unit And Utility Tests
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-ux-readiness
|
||||
description: Home Assistant frontend UI/UX readiness routing for new cards, badges, screens, and interaction patterns. Use it with `ha-frontend-review` to decide whether frontend review is enough or further UI/UX input is needed. Skip it for PRs already carrying Needs UX and for fixes or technical details that preserve an established experience.
|
||||
---
|
||||
|
||||
# HA Frontend UI/UX Readiness
|
||||
|
||||
Use the design context and evidence gathered through `ha-frontend-review` to decide whether a change's UI/UX direction is settled and whether further UI/UX review should be recommended or is explicitly required. This skill routes the result; it does not repeat or replace implementation, accessibility, or frontend quality review.
|
||||
|
||||
## Early Exit
|
||||
|
||||
If the PR already carries the **Needs UX** label, reviewers have already decided to ask designers or UX reviewers for guidance. Do not use this skill to make that recommendation again.
|
||||
|
||||
## Triage
|
||||
|
||||
1. Apply `ha-frontend-review` and record its UI/UX evidence.
|
||||
- Include the applicable gallery specification, production or approved designs, shared `ha-*` components, task requirements, historical feedback, and direct instructions.
|
||||
- Keep the implementation review separate from this readiness decision.
|
||||
2. Classify the user-facing decision.
|
||||
- Existing-feature fixes and technical details normally remain with frontend review when they preserve the established experience.
|
||||
- New cards, badges, screens, and interaction patterns normally need this readiness check.
|
||||
3. Decide whether the evidence gathered during frontend review settles the direction.
|
||||
- Frontend maintainer guidance, explicit UI/UX approval, current guidance, an established pattern, or a task that settles the material visual and interaction choices can settle the direction.
|
||||
- Insufficient or conflicting evidence leaves the direction unresolved.
|
||||
4. Select exactly one route below and state which frontend-review evidence supports it.
|
||||
|
||||
## Routes
|
||||
|
||||
- **Frontend review sufficient:** The change preserves the established experience, or the evidence settles the material visual and interaction choices. Cite that evidence and continue with `ha-frontend-review`.
|
||||
- **UI/UX review recommended:** A new user experience has material design choices that remain unresolved. Identify the decision that needs input and recommend UI/UX review.
|
||||
- **UI/UX review required:** A workflow rule or direct human instruction explicitly requires UI/UX review. Cite that evidence. A new design, task link, or lack of evidence is not enough on its own.
|
||||
|
||||
Frontend maintainer guidance, explicit UI/UX approval, established patterns, and task requirements inform the route. A task can settle requirements without settling the UI/UX. Recommend UI/UX review when important design choices remain unresolved.
|
||||
|
||||
## Reporting
|
||||
|
||||
Keep the result concise and use this structure:
|
||||
|
||||
- **Route:** Frontend review sufficient, UI/UX review recommended, or UI/UX review required
|
||||
- **Established patterns:** relevant gallery specifications, production designs, and shared `ha-*` components for the next reviewer to compare
|
||||
- **Supporting evidence:** approval, guidance, established patterns, task requirements, direct instructions, or the evidence gap or conflict
|
||||
- **Next action:** the review action implied by the route
|
||||
|
||||
Separate whether the UI/UX direction is settled from whether the implementation is correct.
|
||||
+24
-40
@@ -1,52 +1,36 @@
|
||||
[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 2 years + current alpha/beta versions
|
||||
# - released in the last year + current alpha/beta versions
|
||||
# - Firefox extended support release (ESR)
|
||||
# - with global utilization at or above 0.5%
|
||||
# - exclude dead browsers (no security maintenance for 2+ years)
|
||||
# - exclude QQ, and UC browsers due to lack of sufficient feature support data
|
||||
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
|
||||
unreleased versions
|
||||
last 2 years
|
||||
last 1 year
|
||||
Firefox ESR
|
||||
>= 0.5%
|
||||
not dead
|
||||
not KaiOS > 0
|
||||
not QQAndroid > 0
|
||||
not UCAndroid > 0
|
||||
|
||||
[legacy]
|
||||
# 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 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-sw]
|
||||
# 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
|
||||
# Same as legacy plus supports service workers
|
||||
unreleased versions
|
||||
last 7 years
|
||||
>= 0.05% and supports serviceworkers
|
||||
not dead
|
||||
not op_mini all
|
||||
|
||||
@@ -11,9 +11,6 @@ inputs:
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
rspack-cache:
|
||||
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -24,4 +21,3 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
RSPACK_CACHE: ${{ inputs.rspack-cache }}
|
||||
|
||||
@@ -97,12 +97,10 @@ jobs:
|
||||
run: yarn run test
|
||||
build:
|
||||
name: Build frontend
|
||||
# Runs alongside lint and test rather than after them: the build only needs
|
||||
# the dependency tree, and with the rspack cache it is no longer expensive
|
||||
# enough to be worth serialising behind the other checks. The
|
||||
# cancel-on-failure job below stops the run as soon as a check fails, so a
|
||||
# broken pull request does not finish building.
|
||||
needs: prepare-dependencies
|
||||
needs:
|
||||
- prepare-dependencies
|
||||
- lint
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -113,26 +111,12 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
# Read-only reuse of the rspack cache written by the nightly (see
|
||||
# nightly.yaml). rspack itself decides what is still valid (version +
|
||||
# buildDependencies + node_modules snapshot), so the GHA key just restores
|
||||
# the latest nightly cache; no fingerprint, and no save step (CI never
|
||||
# writes the shared cache).
|
||||
- name: Restore rspack cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
rspack-cache: readonly
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -148,54 +132,3 @@ jobs:
|
||||
path: hass_frontend/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Now that the checks run in parallel, a failing lint or test no longer stops
|
||||
# the build from finishing on its own, so this watches them and cancels the
|
||||
# whole run on the first failure.
|
||||
#
|
||||
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
|
||||
# other jobs check out the pull request and run its build scripts — handing
|
||||
# them that scope would give PR-controlled code (or a compromised dependency)
|
||||
# write access to Actions. This job never checks out the repository, so the
|
||||
# elevated token stays away from PR code. It also cannot be a job that
|
||||
# `needs` the checks: that would only start once they have all finished, which
|
||||
# is exactly too late to cancel anything.
|
||||
cancel-on-failure:
|
||||
name: Cancel run on failure
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Cancel the run when a check fails
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
watched='^(Lint and check format|Run tests|Build frontend)$'
|
||||
while :; do
|
||||
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
||||
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
|
||||
2>/dev/null || true)
|
||||
|
||||
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
|
||||
if [ -n "$failed" ]; then
|
||||
echo "Cancelling the run, these checks failed:"
|
||||
printf '%s\n' "$failed"
|
||||
gh run cancel "$RUN_ID" --repo "$REPO" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
|
||||
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && $2 != "completed" { print $1 }')
|
||||
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
|
||||
echo "All checks finished without failure"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 15
|
||||
done
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -137,7 +137,6 @@ jobs:
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -58,24 +58,9 @@ jobs:
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
# The nightly writes the rspack persistent cache; CI reads it read-only
|
||||
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
|
||||
# + node_modules snapshot), so the cache rolls forward daily and a single
|
||||
# dependency bump keeps most of it warm instead of dropping the lineage.
|
||||
- name: Restore rspack cache
|
||||
id: rspack-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build nightly Python wheels
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
RSPACK_CACHE: readwrite
|
||||
run: |
|
||||
pip install build
|
||||
yarn install
|
||||
@@ -84,7 +69,7 @@ jobs:
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build
|
||||
|
||||
# Not gated on the restore steps: a transient restore failure (they are
|
||||
# Not gated on the restore step: a transient restore failure (it is
|
||||
# continue-on-error) must not stop us persisting a freshly built cache.
|
||||
- name: Save compression cache
|
||||
if: success()
|
||||
@@ -94,14 +79,6 @@ jobs:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Save rspack cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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`.
|
||||
+5
-6
@@ -1,8 +1,7 @@
|
||||
name: Sync sensor entity constants
|
||||
name: Sync numeric device classes
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# when it drifts. Reads homeassistant/generated/sensor.json from core.
|
||||
|
||||
on:
|
||||
@@ -29,8 +28,8 @@ jobs:
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate sensor entity constants
|
||||
run: ./script/gen_sensor_entity_constants
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
|
||||
- name: Format
|
||||
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
|
||||
@@ -7,7 +7,6 @@ dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
/.compress-cache/
|
||||
/.rspack-cache/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
@@ -74,4 +73,3 @@ test/e2e/app/dist/
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,11 +33,10 @@ 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
|
||||
|
||||
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
|
||||
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
|
||||
|
||||
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
|
||||
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
|
||||
@@ -47,8 +46,7 @@ Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching
|
||||
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, existing review feedback, review checklist, recurring issues, and UI/UX evidence including gallery specifications.
|
||||
- `ha-frontend-ux-readiness`: UI/UX routing that complements frontend review for new user experiences.
|
||||
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
|
||||
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
|
||||
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
|
||||
|
||||
|
||||
@@ -34,14 +34,6 @@ In production, the following responsibilities are added:
|
||||
|
||||
- Minify HTML
|
||||
- Bundle multiple imports so that the browser can fetch less files
|
||||
- Generate a second version that is compatible with older browsers (legacy build)
|
||||
- Generate a second version that is ES5 compatible
|
||||
|
||||
Configuration for all these steps are specified in [bundle.js](bundle.js).
|
||||
|
||||
## Auditing browser support changes
|
||||
|
||||
`node build-scripts/list-plugins-and-polyfills.js` prints, per browserslist
|
||||
environment (modern/legacy), the Babel transforms preset-env enables and the
|
||||
Core-JS polyfills that may be injected — as collapsible markdown ready to
|
||||
paste into a PR. Use it to show the bundle impact when changing
|
||||
`.browserslistrc` or the Babel configuration.
|
||||
|
||||
@@ -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": 576583,
|
||||
"core": 54790,
|
||||
"authorize": 543547,
|
||||
"onboarding": 655556
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 717124,
|
||||
"core": 181583,
|
||||
"authorize": 699175,
|
||||
"onboarding": 816133
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,10 +63,8 @@ module.exports.htmlMinifierOptions = {
|
||||
};
|
||||
|
||||
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
|
||||
// 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,
|
||||
safari10: !latestBuild,
|
||||
ecma: latestBuild ? 2015 : 5,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
sourceMap: !isTestBuild,
|
||||
@@ -121,7 +119,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
ignoreModuleNotFound: true,
|
||||
},
|
||||
],
|
||||
// Import helpers from runtime package.
|
||||
// Import helpers and regenerator from runtime package.
|
||||
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
|
||||
// corejs3 polyfill provider above otherwise redirects them to the
|
||||
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
|
||||
@@ -155,6 +153,8 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
"proxy-polyfill",
|
||||
"unfetch",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
],
|
||||
},
|
||||
@@ -277,7 +277,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
gallery({ isProdBuild, latestBuild, isTestBuild }) {
|
||||
gallery({ isProdBuild, latestBuild }) {
|
||||
return {
|
||||
name: "gallery" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -287,7 +287,6 @@ module.exports.config = {
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isTestBuild,
|
||||
defineOverlay: {
|
||||
__DEMO__: true,
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
|
||||
16: [11, 0, 0],
|
||||
17: [12, 0, 0],
|
||||
18: [13, 0, 0],
|
||||
26: [14, 6, 0],
|
||||
26: [26, 0, 0],
|
||||
};
|
||||
|
||||
const getCommonTemplateVars = () => {
|
||||
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
|
||||
...htmlMinifierOptions,
|
||||
conservativeCollapse: false,
|
||||
minifyJS: terserOptions({
|
||||
latestBuild: false, // Shared scripts must satisfy the legacy targets
|
||||
latestBuild: false, // Shared scripts should be ES5
|
||||
isTestBuild: true, // Don't need source maps
|
||||
}),
|
||||
}).then((wrapped) =>
|
||||
|
||||
@@ -4,7 +4,6 @@ import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
@@ -42,6 +41,37 @@ function copyMdiIcons(staticDir) {
|
||||
fs.copySync(polyPath("build/mdi"), staticPath("mdi"));
|
||||
}
|
||||
|
||||
function copyPolyfills(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
|
||||
// For custom panels using ES5 builds that don't use Babel 7+
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
|
||||
// Web Component polyfills and adapters
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
copyFileDir(
|
||||
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js.map"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
// Lit polyfill support
|
||||
fs.copySync(
|
||||
npmPath("lit/polyfill-support.js"),
|
||||
path.join(staticPath("polyfills/"), "lit-polyfill-support.js")
|
||||
);
|
||||
|
||||
// dialog-polyfill css
|
||||
copyFileDir(
|
||||
npmPath("dialog-polyfill/dialog-polyfill.css"),
|
||||
staticPath("polyfills/")
|
||||
);
|
||||
}
|
||||
|
||||
function copyFonts(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Local fonts
|
||||
@@ -59,7 +89,7 @@ function copyQrScannerWorker(staticDir) {
|
||||
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
|
||||
}
|
||||
|
||||
async function copyMapPanel(staticDir) {
|
||||
function copyMapPanel(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
copyFileDir(
|
||||
npmPath("leaflet/dist/leaflet.css"),
|
||||
@@ -73,16 +103,6 @@ async function copyMapPanel(staticDir) {
|
||||
npmPath("leaflet/dist/images"),
|
||||
staticPath("images/leaflet/images/")
|
||||
);
|
||||
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
copyFileDir(
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
// Controls and popups of the native MapLibre engine
|
||||
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
@@ -112,13 +132,14 @@ gulp.task("copy-static-app", async () => {
|
||||
const staticDir = paths.app_output_static;
|
||||
// Basic static files
|
||||
fs.copySync(polyPath("public"), paths.app_output_root);
|
||||
copyPolyfills(staticDir);
|
||||
copyFonts(staticDir);
|
||||
copyTranslations(staticDir);
|
||||
copyLocaleData(staticDir);
|
||||
copyMdiIcons(staticDir);
|
||||
|
||||
// Panel assets
|
||||
await copyMapPanel(staticDir);
|
||||
copyMapPanel(staticDir);
|
||||
|
||||
// Qr Scanner assets
|
||||
copyZXingWasm(staticDir);
|
||||
@@ -133,7 +154,8 @@ gulp.task("copy-static-demo", async () => {
|
||||
);
|
||||
// Copy demo static files
|
||||
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
|
||||
await copyMapPanel(paths.demo_output_static);
|
||||
copyPolyfills(paths.demo_output_static);
|
||||
copyMapPanel(paths.demo_output_static);
|
||||
copyFonts(paths.demo_output_static);
|
||||
copyTranslations(paths.demo_output_static);
|
||||
copyLocaleData(paths.demo_output_static);
|
||||
@@ -145,7 +167,8 @@ gulp.task("copy-static-cast", async () => {
|
||||
fs.copySync(polyPath("public/static"), paths.cast_output_static);
|
||||
// Copy cast static files
|
||||
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
|
||||
await copyMapPanel(paths.cast_output_static);
|
||||
copyPolyfills(paths.cast_output_static);
|
||||
copyMapPanel(paths.cast_output_static);
|
||||
copyFonts(paths.cast_output_static);
|
||||
copyTranslations(paths.cast_output_static);
|
||||
copyLocaleData(paths.cast_output_static);
|
||||
@@ -161,7 +184,7 @@ gulp.task("copy-static-gallery", async () => {
|
||||
paths.gallery_output_root
|
||||
);
|
||||
|
||||
await copyMapPanel(paths.gallery_output_static);
|
||||
copyMapPanel(paths.gallery_output_static);
|
||||
copyFonts(paths.gallery_output_static);
|
||||
copyTranslations(paths.gallery_output_static);
|
||||
copyLocaleData(paths.gallery_output_static);
|
||||
@@ -191,7 +214,8 @@ gulp.task("copy-static-e2e-test-app", async () => {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
await copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
|
||||
@@ -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/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);
|
||||
});
|
||||
@@ -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/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);
|
||||
});
|
||||
@@ -1,83 +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_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,12 +9,10 @@ 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-sensor-entity-constants.js";
|
||||
import "./gen-numeric-device-classes.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./map-assets.js";
|
||||
import "./rspack.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Generates the MapLibre styles for the vector base map.
|
||||
//
|
||||
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
|
||||
// is what lets them be requested with an application User-Agent and without a
|
||||
// referrer. The styles stay here because they come from @versatiles/style and
|
||||
// core has no node toolchain to regenerate them with.
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { colorful, eclipse } from "@versatiles/style";
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
import { addLatinLabels } from "./map-labels.js";
|
||||
|
||||
const PROXY_PATH = "/api/map_tiles";
|
||||
const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`;
|
||||
|
||||
const outputDir = path.resolve(paths.build_dir, "map");
|
||||
|
||||
// MapLibre extends the fetched TileJSON with the style's source options, so
|
||||
// anything left here wins and freezes at build time. Dropping them is what lets
|
||||
// the proxy move the attribution and zoom range too, not just the URLs.
|
||||
const TILEJSON_FIELDS = [
|
||||
"tiles",
|
||||
"attribution",
|
||||
"bounds",
|
||||
"minzoom",
|
||||
"maxzoom",
|
||||
"scheme",
|
||||
];
|
||||
|
||||
// The builder can only write a tile URL, so the source is repointed afterwards.
|
||||
// Keyed on there being exactly one source: any other shape means the builder's
|
||||
// own default host would ship unnoticed.
|
||||
const useTileJson = (name, style) => {
|
||||
const sources = Object.values(style.sources);
|
||||
|
||||
if (sources.length !== 1) {
|
||||
throw new Error(
|
||||
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
|
||||
`point at the TileJSON. Check what @versatiles/style emits.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of TILEJSON_FIELDS) {
|
||||
delete sources[0][field];
|
||||
}
|
||||
sources[0].url = TILEJSON_URL;
|
||||
return style;
|
||||
};
|
||||
|
||||
const styleOptions = {
|
||||
// Keeps the generated URLs origin relative.
|
||||
baseUrl: "",
|
||||
glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
|
||||
sprite: [{ id: "basics", url: `${PROXY_PATH}/sprites/basics/sprites` }],
|
||||
};
|
||||
|
||||
const buildMapAssets = async () => {
|
||||
await fs.emptyDir(outputDir);
|
||||
|
||||
await Promise.all(
|
||||
// Both themes up front: dark is a real cartography, not an inverted raster.
|
||||
[
|
||||
["light", colorful],
|
||||
["dark", eclipse],
|
||||
].map(([name, builder]) =>
|
||||
writeFile(
|
||||
path.join(outputDir, `${name}.json`),
|
||||
JSON.stringify(addLatinLabels(useTileJson(name, builder(styleOptions))))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Shared so it does not have to be wired into every pipeline separately.
|
||||
let pending;
|
||||
export const ensureMapAssets = () => {
|
||||
pending ??= buildMapAssets();
|
||||
return pending;
|
||||
};
|
||||
|
||||
gulp.task("build-map-assets", ensureMapAssets);
|
||||
|
||||
export const mapAssetsDir = outputDir;
|
||||
@@ -1,42 +0,0 @@
|
||||
// Adds the English name to labels whose local name is not in Latin script.
|
||||
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
|
||||
|
||||
const NAME = ["get", "name"];
|
||||
const NAME_EN = ["get", "name_en"];
|
||||
|
||||
// Strings compare by code point: anything from Basic Latin up to Latin
|
||||
// Extended-B, digits and punctuation included.
|
||||
const IS_LATIN = ["<", NAME, "ɐ"];
|
||||
|
||||
const ENGLISH_SCALE = 0.8;
|
||||
|
||||
// Streets are line-placed and cannot break lines.
|
||||
const withEnglish = (placement) =>
|
||||
placement === "line"
|
||||
? ["concat", NAME, " (", NAME_EN, ")"]
|
||||
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
|
||||
|
||||
const isNameLabel = (layer) =>
|
||||
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
|
||||
|
||||
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
|
||||
),
|
||||
});
|
||||
@@ -252,7 +252,6 @@ gulp.task("rspack-prod-gallery", () =>
|
||||
createGalleryConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,55 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
|
||||
|
||||
import { transformSync } from "@babel/core";
|
||||
import compilationTargets, {
|
||||
getInclusionReasons,
|
||||
} from "@babel/helper-compilation-targets";
|
||||
import { version as babelVersion } from "@babel/core";
|
||||
import presetEnv from "@babel/preset-env";
|
||||
import compilationTargets from "@babel/helper-compilation-targets";
|
||||
import coreJSCompat from "core-js-compat";
|
||||
import { logPlugin } from "@babel/preset-env/lib/debug.js";
|
||||
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
|
||||
import { babelOptions } from "./bundle.cjs";
|
||||
|
||||
const detailsOpen = (heading) =>
|
||||
`<details>\n<summary><h4>${heading}</h4></summary>\n`;
|
||||
const detailsClose = "</details>\n";
|
||||
|
||||
// Copied from @babel/preset-env's internal `logPlugin`, which Babel 8 no
|
||||
// longer exposes (the package rolls up into lib/index.js and exports nothing
|
||||
// but the preset). Prints an item with the targets that require it.
|
||||
const logPlugin = (item, targetVersions, list) => {
|
||||
const filteredList = getInclusionReasons(item, targetVersions, list);
|
||||
const support = list[item];
|
||||
if (!support) {
|
||||
console.log(` ${item}`);
|
||||
return;
|
||||
}
|
||||
let formattedTargets = `{`;
|
||||
let first = true;
|
||||
for (const target of Object.keys(filteredList)) {
|
||||
if (!first) formattedTargets += `,`;
|
||||
first = false;
|
||||
formattedTargets += ` ${target}`;
|
||||
if (support[target]) formattedTargets += ` < ${support[target]}`;
|
||||
}
|
||||
formattedTargets += ` }`;
|
||||
console.log(` ${item} ${formattedTargets}`);
|
||||
const dummyAPI = {
|
||||
version: babelVersion,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
assertVersion: () => {},
|
||||
caller: (callback) =>
|
||||
callback({
|
||||
name: "Dummy Bundler",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
}),
|
||||
targets: () => ({}),
|
||||
};
|
||||
|
||||
// Copied from babel-plugin-polyfill-corejs3's generated
|
||||
// corejs3ShippedProposalsList, which v1 no longer exposes (it is inlined in
|
||||
// the package's rolled-up bundle).
|
||||
const shippedProposalsList = new Set([
|
||||
"esnext.array.group",
|
||||
"esnext.array.group-to-map",
|
||||
"esnext.iterator.zip",
|
||||
"esnext.iterator.zip-keyed",
|
||||
"esnext.symbol.metadata",
|
||||
]);
|
||||
|
||||
// Generate filter function based on proposal/method inputs
|
||||
// Copied and adapted from babel-plugin-polyfill-corejs3/esm/index.mjs
|
||||
const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
if (proposals || method === "entry-global") return true;
|
||||
if (shippedProposals && shippedProposalsList.has(name)) {
|
||||
if (shippedProposals && shippedPolyfills.default.has(name)) {
|
||||
return true;
|
||||
}
|
||||
if (name.startsWith("esnext.")) {
|
||||
@@ -64,9 +47,7 @@ const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
|
||||
for (const buildType of ["Modern", "Legacy"]) {
|
||||
const browserslistEnv = buildType.toLowerCase();
|
||||
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
|
||||
const presetEnvOpts = babelOpts.presets.find(
|
||||
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
|
||||
)?.[1];
|
||||
const presetEnvOpts = babelOpts.presets[0][1];
|
||||
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
|
||||
// removed preset-env's `useBuiltIns`), so read its options here.
|
||||
const corejsOpts = babelOpts.plugins.find(
|
||||
@@ -74,38 +55,22 @@ for (const buildType of ["Modern", "Legacy"]) {
|
||||
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
|
||||
)?.[1];
|
||||
|
||||
// Transforming an empty file with preset-env in debug mode logs the included
|
||||
// plugins. The caller declares the same capabilities babel-loader does, so
|
||||
// plugins gated on bundler support (e.g. transform-export-namespace-from)
|
||||
// match the build.
|
||||
presetEnvOpts.debug = true;
|
||||
// Invoking preset-env in debug mode will log the included plugins
|
||||
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
|
||||
transformSync("", {
|
||||
...babelOpts,
|
||||
configFile: false,
|
||||
filename: "audit.js",
|
||||
caller: {
|
||||
name: "list-plugins-and-polyfills",
|
||||
supportsStaticESM: true,
|
||||
supportsDynamicImport: true,
|
||||
supportsTopLevelAwait: true,
|
||||
supportsExportNamespaceFrom: true,
|
||||
},
|
||||
presetEnv.default(dummyAPI, {
|
||||
...presetEnvOpts,
|
||||
browserslistEnv,
|
||||
debug: true,
|
||||
});
|
||||
console.log(detailsClose);
|
||||
|
||||
// Manually log the Core-JS polyfills using the same technique
|
||||
if (corejsOpts) {
|
||||
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
|
||||
const targets = compilationTargets(babelOpts.targets, {
|
||||
const targets = compilationTargets.default(babelOpts?.targets, {
|
||||
browserslistEnv,
|
||||
});
|
||||
// `version` limits the list to modules the installed core-js ships,
|
||||
// mirroring the provider's own filtering.
|
||||
const polyfillList = coreJSCompat({
|
||||
targets,
|
||||
version: corejsOpts.version,
|
||||
}).list.filter(
|
||||
const polyfillList = coreJSCompat({ targets }).list.filter(
|
||||
polyfillFilter(
|
||||
corejsOpts.method,
|
||||
corejsOpts.proposals,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
const fs = require("fs");
|
||||
|
||||
const { existsSync } = fs;
|
||||
const { existsSync } = require("fs");
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -18,61 +16,6 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
const bundle = require("./bundle.cjs");
|
||||
|
||||
// Build-toolchain packages whose version changes the emitted bytes but which
|
||||
// are loader/compiler machinery, not modules in the build graph — so rspack's
|
||||
// node_modules snapshot cannot see them. Their versions are folded into the
|
||||
// persistent cache `version` so a toolchain upgrade invalidates the cache,
|
||||
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
|
||||
const TOOLCHAIN_PACKAGES = [
|
||||
"@rspack/core",
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"babel-plugin-polyfill-corejs3",
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-private-methods",
|
||||
"@babel/runtime",
|
||||
"babel-loader",
|
||||
"core-js",
|
||||
"terser",
|
||||
"terser-webpack-plugin",
|
||||
"browserslist",
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
// absolute paths — rspack compares buildDependencies by path, which breaks a
|
||||
// cache reused on another machine/checkout (a different workspace path).
|
||||
const CONFIG_FILES = [
|
||||
__filename,
|
||||
path.join(__dirname, "bundle.cjs"),
|
||||
path.join(__dirname, "minify-template-literals-loader.cjs"),
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
];
|
||||
|
||||
// Content hash of the toolchain versions and our own build files, used as the
|
||||
// persistent cache `version`. Everything here is path-independent so the cache
|
||||
// stays valid when reused on a different machine or checkout path.
|
||||
const cacheVersion = () => {
|
||||
const parts = [
|
||||
...TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
),
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
];
|
||||
return require("crypto")
|
||||
.createHash("sha256")
|
||||
.update(parts.join("\n"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
};
|
||||
|
||||
class LogStartCompilePlugin {
|
||||
ignoredFirst = false;
|
||||
|
||||
@@ -345,7 +288,10 @@ const createRspackConfig = ({
|
||||
"lit/directives/join$": "lit/directives/join.js",
|
||||
"lit/directives/repeat$": "lit/directives/repeat.js",
|
||||
"lit/directives/live$": "lit/directives/live.js",
|
||||
"lit/directives/keyed$": "lit/directives/keyed.js",
|
||||
"lit/directives/keyed$": latestBuild
|
||||
? "lit/directives/keyed.js"
|
||||
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
|
||||
"lit/polyfill-support$": "lit/polyfill-support.js",
|
||||
"@lit-labs/virtualizer/layouts/grid":
|
||||
"@lit-labs/virtualizer/layouts/grid.js",
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
|
||||
@@ -430,33 +376,6 @@ const createRspackConfig = ({
|
||||
])
|
||||
),
|
||||
},
|
||||
// Persistent filesystem cache for production builds, opt-in per environment
|
||||
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
|
||||
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
|
||||
// tests) = no cache.
|
||||
...(isProdBuild && process.env.RSPACK_CACHE
|
||||
? {
|
||||
cache: {
|
||||
type: "persistent",
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Content-based version (node major + toolchain versions + our own
|
||||
// build files). Everything is path-independent, so the cache stays
|
||||
// valid when reused on another machine/checkout. Runtime deps are
|
||||
// deliberately absent — rspack's node_modules snapshot invalidates
|
||||
// their modules per-package, so a single unrelated bump keeps the
|
||||
// rest warm. buildDependencies is intentionally not used: rspack
|
||||
// compares it by absolute path, which breaks cross-machine reuse.
|
||||
version: `node${process.versions.node.split(".")[0]}-${cacheVersion()}`,
|
||||
storage: {
|
||||
type: "filesystem",
|
||||
directory: path.resolve(paths.root_dir, ".rspack-cache"),
|
||||
},
|
||||
// CI reads the nightly-written cache but must not modify it.
|
||||
readonly: process.env.RSPACK_CACHE === "readonly",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
@@ -486,10 +405,8 @@ const createDemoConfig = ({
|
||||
const createCastConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.cast({ isProdBuild, latestBuild }));
|
||||
|
||||
const createGalleryConfig = ({ isProdBuild, latestBuild, isTestBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.gallery({ isProdBuild, latestBuild, isTestBuild })
|
||||
);
|
||||
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
|
||||
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
saveTokens,
|
||||
} from "../../../../src/common/auth/token_storage";
|
||||
import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-icon";
|
||||
import "../../../../src/components/ha-list";
|
||||
@@ -196,7 +197,8 @@ class HcCast extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
this.toggleAttribute(
|
||||
toggleAttribute(
|
||||
this,
|
||||
"hide-icons",
|
||||
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { connectivityEntities } from "../stubs/connectivity/fixtures";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
@@ -54,9 +53,6 @@ export const setDemoConfig = async (
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
// Replaced the whole state map above, so the entities that do not belong to a
|
||||
// demo config have to be added back.
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Let the new registries and entities reach the dashboard before saving the
|
||||
// config, so dashboard strategies generate against them
|
||||
|
||||
+1
-11
@@ -7,12 +7,6 @@ import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import {
|
||||
connectivityCommands,
|
||||
connectivityComponents,
|
||||
connectivityEntities,
|
||||
connectivityEntityRegistryEntries,
|
||||
} from "./stubs/connectivity/fixtures";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
@@ -66,7 +60,6 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
...connectivityCommands,
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -94,7 +87,6 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
...connectivityComponents,
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -107,7 +99,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
mockLovelace(hass, localizePromise);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass, localizePromise);
|
||||
mockTranslations(hass);
|
||||
mockHistory(hass);
|
||||
mockRecorder(hass);
|
||||
mockTodo(hass);
|
||||
@@ -180,11 +172,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
...connectivityEntityRegistryEntries,
|
||||
]);
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
hass.addEntities(connectivityEntities());
|
||||
|
||||
// Once config is loaded AND localize, set registries, entities and theme.
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
|
||||
@@ -7,7 +7,6 @@ import { mockBlueprint } from "./blueprint";
|
||||
import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockConnectivity } from "./connectivity";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
@@ -27,7 +26,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockCloud(hass);
|
||||
mockConfig(hass);
|
||||
mockConfigEntries(hass);
|
||||
mockConnectivity(hass);
|
||||
mockDeviceAutomation(hass);
|
||||
mockEntitySources(hass);
|
||||
mockBlueprint(hass);
|
||||
@@ -45,10 +43,4 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
hass.mockWS("llm/api/list", () => ({
|
||||
apis: [
|
||||
{ id: "assist", name: "Assist" },
|
||||
{ id: "music_assistant", name: "Music Assistant" },
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
|
||||
import type { IntegrationType } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityConfigEntries } from "./connectivity/fixtures";
|
||||
|
||||
const baseEntry = {
|
||||
source: "user",
|
||||
@@ -20,7 +19,6 @@ 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,
|
||||
};
|
||||
@@ -81,17 +79,6 @@ export const demoConfigEntries: {
|
||||
title: "Comfort level",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-mcp-server",
|
||||
domain: "mcp_server",
|
||||
title: "Assist, Music Assistant",
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
...connectivityConfigEntries,
|
||||
];
|
||||
|
||||
const filterEntries = (filters?: {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import { configEntry, device } from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const LOCAL_SOURCE = "00:1A:7D:DA:71:11";
|
||||
export const PROXY_SOURCE = "E8:DB:84:A1:C2:30";
|
||||
export const SHED_SOURCE = "A4:CF:12:9B:44:70";
|
||||
|
||||
const ADAPTER_ENTRY_ID = "mock-bluetooth";
|
||||
const PROXY_LIVING_ENTRY_ID = "mock-bluetooth-proxy-living";
|
||||
const PROXY_SHED_ENTRY_ID = "mock-bluetooth-proxy-shed";
|
||||
|
||||
export const bluetoothFixtures: ConnectivityFixtures = {
|
||||
components: ["bluetooth"],
|
||||
commands: ["bluetooth/"],
|
||||
manifests: [manifest("bluetooth", "Bluetooth", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
ADAPTER_ENTRY_ID,
|
||||
"bluetooth",
|
||||
`hci0 (${LOCAL_SOURCE})`,
|
||||
{ source: "usb", supports_options: true }
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
PROXY_LIVING_ENTRY_ID,
|
||||
"bluetooth",
|
||||
"Living room proxy",
|
||||
{ source: "esphome" }
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(PROXY_SHED_ENTRY_ID, "bluetooth", "Shed proxy", {
|
||||
source: "esphome",
|
||||
}),
|
||||
},
|
||||
],
|
||||
// Adapters and proxies are matched to their scanner by the bluetooth
|
||||
// connection tuple, see ./mock.
|
||||
devices: [
|
||||
device(
|
||||
"bluetooth-hci0",
|
||||
"hci0",
|
||||
"Home Assistant",
|
||||
"Home Assistant Green",
|
||||
ADAPTER_ENTRY_ID,
|
||||
{ connections: [["bluetooth", LOCAL_SOURCE]] }
|
||||
),
|
||||
device(
|
||||
"bluetooth-proxy-living",
|
||||
"Living room proxy",
|
||||
"Espressif",
|
||||
"ESP32-C3",
|
||||
PROXY_LIVING_ENTRY_ID,
|
||||
{ area_id: "living_room", connections: [["bluetooth", PROXY_SOURCE]] }
|
||||
),
|
||||
device(
|
||||
"bluetooth-proxy-shed",
|
||||
"Shed proxy",
|
||||
"Espressif",
|
||||
"ESP32",
|
||||
PROXY_SHED_ENTRY_ID,
|
||||
{ connections: [["bluetooth", SHED_SOURCE]] }
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
import type {
|
||||
BluetoothAllocationsData,
|
||||
BluetoothDeviceData,
|
||||
BluetoothScannerDetails,
|
||||
BluetoothScannerState,
|
||||
} from "../../../../../src/data/bluetooth";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
import { LOCAL_SOURCE, PROXY_SOURCE, SHED_SOURCE } from "./fixtures";
|
||||
|
||||
const SCANNERS: BluetoothScannerDetails[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
connectable: true,
|
||||
name: "hci0 (Home Assistant Green)",
|
||||
adapter: "hci0",
|
||||
scanner_type: "usb",
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
connectable: true,
|
||||
name: "Living room proxy",
|
||||
adapter: "esp32",
|
||||
scanner_type: "remote",
|
||||
},
|
||||
{
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
name: "Shed proxy",
|
||||
adapter: "esp32",
|
||||
scanner_type: "remote",
|
||||
},
|
||||
];
|
||||
|
||||
const SCANNER_STATES: BluetoothScannerState[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
adapter: "hci0",
|
||||
current_mode: "active",
|
||||
requested_mode: "active",
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
adapter: "esp32",
|
||||
current_mode: "active",
|
||||
requested_mode: "active",
|
||||
},
|
||||
{
|
||||
// A proxy configured for passive scanning: it reports advertisements but
|
||||
// cannot connect, matching its `connectable: false` scanner details.
|
||||
source: SHED_SOURCE,
|
||||
adapter: "esp32",
|
||||
current_mode: "passive",
|
||||
requested_mode: "passive",
|
||||
},
|
||||
];
|
||||
|
||||
const ALLOCATIONS: BluetoothAllocationsData[] = [
|
||||
{
|
||||
source: LOCAL_SOURCE,
|
||||
slots: 5,
|
||||
free: 3,
|
||||
allocated: ["A4:C1:38:11:22:33", "E7:2E:00:B1:9A:1C"],
|
||||
},
|
||||
{
|
||||
source: PROXY_SOURCE,
|
||||
slots: 3,
|
||||
free: 2,
|
||||
allocated: ["FC:58:FA:12:34:56"],
|
||||
},
|
||||
];
|
||||
|
||||
interface DemoAdvertisement {
|
||||
address: string;
|
||||
name: string;
|
||||
rssi: number;
|
||||
source: string;
|
||||
connectable?: boolean;
|
||||
tx_power?: number;
|
||||
manufacturer_data?: Record<number, string>;
|
||||
service_data?: Record<string, string>;
|
||||
service_uuids?: string[];
|
||||
}
|
||||
|
||||
const ADVERTISEMENTS: DemoAdvertisement[] = [
|
||||
{
|
||||
address: "A4:C1:38:11:22:33",
|
||||
name: "Govee H5075",
|
||||
rssi: -58,
|
||||
source: LOCAL_SOURCE,
|
||||
manufacturer_data: { 60552: "000104a10b64" },
|
||||
service_uuids: ["0000ec88-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "E7:2E:00:B1:9A:1C",
|
||||
name: "SwitchBot Meter",
|
||||
rssi: -71,
|
||||
source: LOCAL_SOURCE,
|
||||
service_data: { "0000fd3d-0000-1000-8000-00805f9b34fb": "5400648c14" },
|
||||
service_uuids: ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
|
||||
},
|
||||
{
|
||||
address: "FC:58:FA:12:34:56",
|
||||
name: "Xiaomi LYWSD03MMC",
|
||||
rssi: -64,
|
||||
source: PROXY_SOURCE,
|
||||
service_data: { "0000fe95-0000-1000-8000-00805f9b34fb": "3058590e" },
|
||||
},
|
||||
{
|
||||
address: "C4:7C:8D:6A:5B:20",
|
||||
name: "Flower care",
|
||||
rssi: -88,
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
service_uuids: ["0000fe95-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "D0:36:9A:7F:11:80",
|
||||
name: "Tile Mate",
|
||||
rssi: -79,
|
||||
source: PROXY_SOURCE,
|
||||
connectable: false,
|
||||
service_uuids: ["0000feed-0000-1000-8000-00805f9b34fb"],
|
||||
},
|
||||
{
|
||||
address: "5C:C7:C1:04:9E:2A",
|
||||
name: "Nut Find 3",
|
||||
rssi: -93,
|
||||
source: SHED_SOURCE,
|
||||
connectable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const buildAdvertisement = (
|
||||
advertisement: DemoAdvertisement
|
||||
): BluetoothDeviceData => ({
|
||||
address: advertisement.address,
|
||||
name: advertisement.name,
|
||||
rssi: advertisement.rssi,
|
||||
source: advertisement.source,
|
||||
connectable: advertisement.connectable ?? true,
|
||||
manufacturer_data: advertisement.manufacturer_data ?? {},
|
||||
service_data: advertisement.service_data ?? {},
|
||||
service_uuids: advertisement.service_uuids ?? [],
|
||||
tx_power: advertisement.tx_power ?? -59,
|
||||
time: Date.now() / 1000,
|
||||
raw: null,
|
||||
});
|
||||
|
||||
// Nudge the signal strength a little on every tick so the monitors and the
|
||||
// network map look alive without the rows jumping around.
|
||||
const jitter = (rssi: number) =>
|
||||
Math.max(-99, Math.min(-30, rssi + Math.round(Math.random() * 4) - 2));
|
||||
|
||||
export const mockBluetooth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("bluetooth/subscribe_scanner_details", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.({ add: SCANNERS }))
|
||||
);
|
||||
|
||||
hass.mockWS("bluetooth/subscribe_scanner_state", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => SCANNER_STATES.forEach((state) => onChange?.(state)))
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"bluetooth/subscribe_connection_allocations",
|
||||
(msg: { config_entry_id?: string }, _hass, onChange) =>
|
||||
emitInitial(() =>
|
||||
onChange?.(
|
||||
msg.config_entry_id
|
||||
? ALLOCATIONS.filter((a) => a.source === LOCAL_SOURCE)
|
||||
: ALLOCATIONS
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
hass.mockWS("bluetooth/subscribe_advertisements", (_msg, _hass, onChange) => {
|
||||
let advertisements = ADVERTISEMENTS;
|
||||
const stopInitial = emitInitial(() =>
|
||||
onChange?.({ add: advertisements.map(buildAdvertisement) })
|
||||
);
|
||||
const interval = window.setInterval(() => {
|
||||
advertisements = advertisements.map((advertisement) => ({
|
||||
...advertisement,
|
||||
rssi: jitter(advertisement.rssi),
|
||||
}));
|
||||
onChange?.({ change: advertisements.map(buildAdvertisement) });
|
||||
}, 5000);
|
||||
return () => {
|
||||
stopInitial();
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { bluetoothFixtures } from "./bluetooth/fixtures";
|
||||
import { infraredFixtures } from "./infrared/fixtures";
|
||||
import { matterFixtures } from "./matter/fixtures";
|
||||
import { modbusFixtures } from "./modbus/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,
|
||||
modbusFixtures,
|
||||
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;
|
||||
}, {});
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { ConfigEntry } from "../../../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type { EntityInput } from "../../../../src/fake_data/entities/types";
|
||||
|
||||
// Builders for the registry fixtures, so each integration only spells out what
|
||||
// makes its own entries different.
|
||||
|
||||
const BASE_CONFIG_ENTRY = {
|
||||
source: "user",
|
||||
state: "loaded" as const,
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
|
||||
export const configEntry = (
|
||||
entryId: string,
|
||||
domain: string,
|
||||
title: string,
|
||||
extra: Partial<ConfigEntry> = {}
|
||||
): ConfigEntry => ({
|
||||
...BASE_CONFIG_ENTRY,
|
||||
entry_id: entryId,
|
||||
domain,
|
||||
title,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const BASE_DEVICE = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
entry_type: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const device = (
|
||||
id: string,
|
||||
name: string,
|
||||
manufacturer: string,
|
||||
model: string,
|
||||
entryId: string,
|
||||
extra: Partial<DeviceRegistryEntry> = {}
|
||||
): DeviceRegistryEntry => ({
|
||||
...BASE_DEVICE,
|
||||
id,
|
||||
name,
|
||||
manufacturer,
|
||||
model,
|
||||
config_entries: [entryId],
|
||||
primary_config_entry: entryId,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const BASE_REGISTRY_ENTRY = {
|
||||
config_subentry_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
icon: null,
|
||||
labels: [] as string[],
|
||||
categories: {},
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
options: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const registryEntry = (
|
||||
entityId: string,
|
||||
deviceId: string,
|
||||
entryId: string,
|
||||
platform: string,
|
||||
name?: string
|
||||
): EntityRegistryEntry => ({
|
||||
...BASE_REGISTRY_ENTRY,
|
||||
entity_id: entityId,
|
||||
id: entityId,
|
||||
unique_id: entityId,
|
||||
device_id: deviceId,
|
||||
config_entry_id: entryId,
|
||||
platform,
|
||||
name: name ?? null,
|
||||
has_entity_name: name === undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* The demo's `addEntities` builds the display entity registry from the entity
|
||||
* inputs, so mirror each state's registry entry onto it. Panels count entities
|
||||
* per device and per integration.
|
||||
*/
|
||||
export const withRegistryLinks = (
|
||||
entries: EntityRegistryEntry[],
|
||||
states: Record<string, EntityInput>
|
||||
): EntityInput[] =>
|
||||
Object.values(states).map((state) => {
|
||||
const entry = entries.find(
|
||||
(candidate) => candidate.entity_id === state.entity_id
|
||||
);
|
||||
return entry
|
||||
? { ...state, device_id: entry.device_id!, platform: entry.platform }
|
||||
: state;
|
||||
});
|
||||
|
||||
export const minutesAgo = (minutes: number) =>
|
||||
new Date(Date.now() - minutes * 60000).toISOString();
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { mockBluetooth } from "./bluetooth/mock";
|
||||
import { mockMatter } from "./matter/mock";
|
||||
import { mockModbus } from "./modbus/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,
|
||||
mockModbus,
|
||||
mockMatter,
|
||||
mockMqtt,
|
||||
mockZwaveJs,
|
||||
mockZha,
|
||||
mockRadioFrequency,
|
||||
mockThread,
|
||||
];
|
||||
|
||||
export const mockConnectivity = (hass: MockHomeAssistant) => {
|
||||
MOCKS.forEach((mock) => mock(hass));
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
minutesAgo,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-broadlink";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"broadlink-living-room",
|
||||
"Living room blaster",
|
||||
"Broadlink",
|
||||
"RM4 pro",
|
||||
ENTRY_ID,
|
||||
{ area_id: "living_room" }
|
||||
),
|
||||
device(
|
||||
"broadlink-bedroom",
|
||||
"Bedroom blaster",
|
||||
"Broadlink",
|
||||
"RM mini 3",
|
||||
ENTRY_ID,
|
||||
{ area_id: "bedroom" }
|
||||
),
|
||||
];
|
||||
|
||||
// The infrared panel is entity driven: the proxy entities live in the
|
||||
// `infrared` domain while their registry platform stays the integration that
|
||||
// provides them.
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"infrared.living_room_blaster_emitter",
|
||||
"broadlink-living-room",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Emitter"
|
||||
),
|
||||
registryEntry(
|
||||
"infrared.living_room_blaster_receiver",
|
||||
"broadlink-living-room",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Receiver"
|
||||
),
|
||||
registryEntry(
|
||||
"infrared.bedroom_blaster_emitter",
|
||||
"broadlink-bedroom",
|
||||
ENTRY_ID,
|
||||
"broadlink",
|
||||
"Emitter"
|
||||
),
|
||||
];
|
||||
|
||||
export const infraredFixtures: ConnectivityFixtures = {
|
||||
components: ["infrared"],
|
||||
manifests: [
|
||||
manifest("broadlink", "Broadlink", {
|
||||
integration_type: "hub",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{ type: "hub", entry: configEntry(ENTRY_ID, "broadlink", "RM4 pro") },
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
// A proxy entity's state is the timestamp it was last used.
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"infrared.living_room_blaster_emitter": {
|
||||
entity_id: "infrared.living_room_blaster_emitter",
|
||||
state: minutesAgo(12),
|
||||
attributes: {
|
||||
friendly_name: "Living room blaster Emitter",
|
||||
device_class: "emitter",
|
||||
},
|
||||
},
|
||||
"infrared.living_room_blaster_receiver": {
|
||||
entity_id: "infrared.living_room_blaster_receiver",
|
||||
state: minutesAgo(3),
|
||||
attributes: {
|
||||
friendly_name: "Living room blaster Receiver",
|
||||
device_class: "receiver",
|
||||
},
|
||||
},
|
||||
"infrared.bedroom_blaster_emitter": {
|
||||
entity_id: "infrared.bedroom_blaster_emitter",
|
||||
state: minutesAgo(1440),
|
||||
attributes: {
|
||||
friendly_name: "Bedroom blaster Emitter",
|
||||
device_class: "emitter",
|
||||
},
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
entity_component: {
|
||||
// The emitter is the default device class, stored under the "_" key.
|
||||
"component.infrared.entity_component._.name": "Emitter",
|
||||
"component.infrared.entity_component.receiver.name": "Receiver",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-matter";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"matter-kitchen-light",
|
||||
"Kitchen ceiling",
|
||||
"Nanoleaf",
|
||||
"Essentials A19",
|
||||
ENTRY_ID,
|
||||
{ area_id: "kitchen", sw_version: "3.5.7" }
|
||||
),
|
||||
device(
|
||||
"matter-side-door-lock",
|
||||
"Side door lock",
|
||||
"Aqara",
|
||||
"Smart Lock U100",
|
||||
ENTRY_ID,
|
||||
{ sw_version: "1.2.0" }
|
||||
),
|
||||
device("matter-office-plug", "Office plug", "Eve", "Energy", ENTRY_ID, {
|
||||
area_id: "office",
|
||||
sw_version: "3.2.0",
|
||||
}),
|
||||
device("matter-patio-sensor", "Patio sensor", "Eve", "Weather", ENTRY_ID, {
|
||||
area_id: "garden",
|
||||
sw_version: "3.2.1",
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"light.kitchen_ceiling",
|
||||
"matter-kitchen-light",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
registryEntry("lock.side_door", "matter-side-door-lock", ENTRY_ID, "matter"),
|
||||
registryEntry("switch.office_plug", "matter-office-plug", ENTRY_ID, "matter"),
|
||||
registryEntry(
|
||||
"sensor.office_plug_power",
|
||||
"matter-office-plug",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.patio_temperature",
|
||||
"matter-patio-sensor",
|
||||
ENTRY_ID,
|
||||
"matter"
|
||||
),
|
||||
];
|
||||
|
||||
export const matterFixtures: ConnectivityFixtures = {
|
||||
components: ["matter"],
|
||||
commands: ["matter/"],
|
||||
manifests: [manifest("matter", "Matter", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "matter", "Matter", {
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.kitchen_ceiling": {
|
||||
entity_id: "light.kitchen_ceiling",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen ceiling",
|
||||
supported_color_modes: ["color_temp"],
|
||||
color_mode: "color_temp",
|
||||
brightness: 204,
|
||||
},
|
||||
},
|
||||
"lock.side_door": {
|
||||
entity_id: "lock.side_door",
|
||||
state: "locked",
|
||||
attributes: { friendly_name: "Side door lock" },
|
||||
},
|
||||
"switch.office_plug": {
|
||||
entity_id: "switch.office_plug",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Office plug" },
|
||||
},
|
||||
"sensor.office_plug_power": {
|
||||
entity_id: "sensor.office_plug_power",
|
||||
state: "42.5",
|
||||
attributes: {
|
||||
friendly_name: "Office plug power",
|
||||
device_class: "power",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "W",
|
||||
},
|
||||
},
|
||||
"sensor.patio_temperature": {
|
||||
entity_id: "sensor.patio_temperature",
|
||||
state: "14.2",
|
||||
attributes: {
|
||||
friendly_name: "Patio temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,404 +0,0 @@
|
||||
import type {
|
||||
MatterCommissioningParameters,
|
||||
MatterFabricData,
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterNodeDiagnostics,
|
||||
} from "../../../../../src/data/matter";
|
||||
import { NetworkType, NodeType } from "../../../../../src/data/matter";
|
||||
import type {
|
||||
MatterLockInfo,
|
||||
MatterLockUser,
|
||||
MatterLockUsersResponse,
|
||||
SetMatterLockCredentialResult,
|
||||
} from "../../../../../src/data/matter-lock";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const EXT_PAN_ID = "dead00beef00cafe";
|
||||
const THREAD_NETWORK = "ha-thread";
|
||||
|
||||
const NODES: MatterNetworkTopologyNode[] = [
|
||||
{
|
||||
id: "otbr",
|
||||
kind: "border_router",
|
||||
network_type: "thread",
|
||||
ha_device_id: null,
|
||||
role: "leader",
|
||||
available: true,
|
||||
ext_address: "f6a1c30d2b4e5f61",
|
||||
rloc16: 0x4000,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
host_name: "homeassistant",
|
||||
vendor_name: "Home Assistant",
|
||||
model_name: "OpenThread Border Router",
|
||||
},
|
||||
{
|
||||
id: "wifi-ap",
|
||||
kind: "wifi_ap",
|
||||
network_type: "wifi",
|
||||
ha_device_id: null,
|
||||
available: true,
|
||||
ssid: "Home",
|
||||
bssid: "3c:37:86:11:22:33",
|
||||
vendor_name: "Ubiquiti",
|
||||
model_name: "U6 Pro",
|
||||
},
|
||||
{
|
||||
id: "node-1",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 1,
|
||||
ha_device_id: "matter-kitchen-light",
|
||||
available: true,
|
||||
role: "router",
|
||||
ext_address: "10a2b3c4d5e6f708",
|
||||
rloc16: 0x8401,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Nanoleaf",
|
||||
model_name: "Essentials A19",
|
||||
},
|
||||
{
|
||||
id: "node-2",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 2,
|
||||
ha_device_id: "matter-side-door-lock",
|
||||
available: true,
|
||||
role: "sleepy_end_device",
|
||||
ext_address: "20b3c4d5e6f70819",
|
||||
rloc16: 0x8402,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Aqara",
|
||||
model_name: "Smart Lock U100",
|
||||
},
|
||||
{
|
||||
id: "node-3",
|
||||
kind: "matter",
|
||||
network_type: "wifi",
|
||||
node_id: 3,
|
||||
ha_device_id: "matter-office-plug",
|
||||
available: true,
|
||||
ssid: "Home",
|
||||
vendor_name: "Eve",
|
||||
model_name: "Energy",
|
||||
},
|
||||
{
|
||||
id: "node-4",
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
node_id: 4,
|
||||
ha_device_id: "matter-patio-sensor",
|
||||
available: false,
|
||||
role: "end_device",
|
||||
ext_address: "30c4d5e6f708192a",
|
||||
rloc16: 0x8403,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
vendor_name: "Eve",
|
||||
model_name: "Weather",
|
||||
},
|
||||
{
|
||||
id: "thread-unknown-1",
|
||||
kind: "thread_unknown",
|
||||
network_type: "thread",
|
||||
available: true,
|
||||
role: "end_device",
|
||||
ext_address: "40d5e6f708192a3b",
|
||||
rloc16: 0x8404,
|
||||
ext_pan_id: EXT_PAN_ID,
|
||||
network_name: THREAD_NETWORK,
|
||||
},
|
||||
];
|
||||
|
||||
const connection = (
|
||||
source: string,
|
||||
target: string,
|
||||
network: string,
|
||||
strength: MatterNetworkTopologyConnection["strength"],
|
||||
lqi?: number,
|
||||
rssi?: number
|
||||
): MatterNetworkTopologyConnection => ({
|
||||
source,
|
||||
target,
|
||||
network,
|
||||
strength,
|
||||
source_to_target: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
|
||||
target_to_source: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
|
||||
via_route_table: false,
|
||||
path_cost: null,
|
||||
});
|
||||
|
||||
const CONNECTIONS: MatterNetworkTopologyConnection[] = [
|
||||
connection("otbr", "node-1", "thread", "strong", 245, -42),
|
||||
connection("otbr", "node-2", "thread", "medium", 160, -68),
|
||||
connection("node-1", "node-4", "thread", "weak", 84, -86),
|
||||
connection("node-1", "thread-unknown-1", "thread", "medium", 172, -63),
|
||||
connection("wifi-ap", "node-3", "wifi", "strong", undefined, -47),
|
||||
];
|
||||
|
||||
const TOPOLOGY: MatterNetworkTopology = {
|
||||
collected_at: Date.now() / 1000,
|
||||
nodes: NODES,
|
||||
connections: CONNECTIONS,
|
||||
};
|
||||
|
||||
const buildTopology = (): MatterNetworkTopology => ({
|
||||
...TOPOLOGY,
|
||||
collected_at: Date.now() / 1000,
|
||||
});
|
||||
|
||||
const FABRICS: MatterFabricData[] = [
|
||||
{
|
||||
fabric_id: 1,
|
||||
vendor_id: 4939,
|
||||
fabric_index: 1,
|
||||
fabric_label: "Home Assistant",
|
||||
vendor_name: "Home Assistant",
|
||||
},
|
||||
];
|
||||
|
||||
const NODE_TYPE_BY_ROLE: Record<string, NodeType> = {
|
||||
router: NodeType.ROUTING_END_DEVICE,
|
||||
sleepy_end_device: NodeType.SLEEPY_END_DEVICE,
|
||||
end_device: NodeType.END_DEVICE,
|
||||
};
|
||||
|
||||
const NODES_BY_DEVICE_ID = new Map(
|
||||
NODES.filter((node) => node.ha_device_id).map((node) => [
|
||||
node.ha_device_id!,
|
||||
node,
|
||||
])
|
||||
);
|
||||
|
||||
const nodeIpAddress = (node: MatterNetworkTopologyNode): string =>
|
||||
node.network_type === "thread"
|
||||
? `fd11:2233:4455:6677::${(node.node_id ?? 0).toString(16)}`
|
||||
: `192.168.1.${100 + (node.node_id ?? 0)}`;
|
||||
|
||||
// Diagnostics are derived from the topology so a device's transport,
|
||||
// availability and node type match the map. The device page reads them per
|
||||
// device, and gates its actions on `available` and `network_type`.
|
||||
const buildNodeDiagnostics = (
|
||||
node: MatterNetworkTopologyNode
|
||||
): MatterNodeDiagnostics => ({
|
||||
node_id: node.node_id!,
|
||||
network_type:
|
||||
node.network_type === "thread" ? NetworkType.THREAD : NetworkType.WIFI,
|
||||
node_type: node.is_bridge
|
||||
? NodeType.BRIDGE
|
||||
: (NODE_TYPE_BY_ROLE[node.role ?? ""] ?? NodeType.END_DEVICE),
|
||||
network_name: node.network_name ?? node.ssid ?? undefined,
|
||||
ip_adresses: [nodeIpAddress(node)],
|
||||
mac_address: node.ext_address?.match(/.{2}/g)?.join(":"),
|
||||
available: node.available !== false,
|
||||
active_fabrics: FABRICS,
|
||||
active_fabric_index: 1,
|
||||
});
|
||||
|
||||
// The backend resolves the device before acting, so both node commands answer
|
||||
// the same way when it cannot.
|
||||
const nodeNotFound = (deviceId: string) =>
|
||||
Promise.reject({
|
||||
code: "node_not_found",
|
||||
message: `No Matter node for device ${deviceId}`,
|
||||
});
|
||||
|
||||
// The manual and QR codes below are the Matter test payload for passcode
|
||||
// 20202021 with discriminator 3840; the three have to agree.
|
||||
const COMMISSIONING_PARAMETERS: MatterCommissioningParameters = {
|
||||
setup_pin_code: 20202021,
|
||||
setup_manual_code: "34970112332",
|
||||
setup_qr_code: "MT:Y.K9042C00KA0648G00",
|
||||
};
|
||||
|
||||
const LOCK_INFO: MatterLockInfo = {
|
||||
supports_user_management: true,
|
||||
supported_credential_types: ["pin"],
|
||||
max_users: 10,
|
||||
max_pin_users: 10,
|
||||
max_rfid_users: null,
|
||||
max_credentials_per_user: 2,
|
||||
min_pin_length: 4,
|
||||
max_pin_length: 8,
|
||||
min_rfid_length: null,
|
||||
max_rfid_length: null,
|
||||
};
|
||||
|
||||
const initialLockUsers = (): MatterLockUser[] => [
|
||||
{
|
||||
user_index: 1,
|
||||
user_name: "Anne",
|
||||
user_unique_id: 1,
|
||||
user_status: "occupied_enabled",
|
||||
user_type: "unrestricted_user",
|
||||
credential_rule: "single",
|
||||
credentials: [{ type: "pin", index: 1 }],
|
||||
next_user_index: 2,
|
||||
},
|
||||
{
|
||||
user_index: 2,
|
||||
user_name: "Cleaner",
|
||||
user_unique_id: 2,
|
||||
user_status: "occupied_disabled",
|
||||
user_type: "week_day_schedule_user",
|
||||
credential_rule: "single",
|
||||
credentials: [{ type: "pin", index: 2 }],
|
||||
next_user_index: null,
|
||||
},
|
||||
];
|
||||
|
||||
// The manage dialog reloads the list after every add, edit and delete, so the
|
||||
// mocked services keep the lock's users rather than answering from a constant,
|
||||
// which would make every change look like it was reverted.
|
||||
const lockUsers = new Map<string, MatterLockUser[]>();
|
||||
|
||||
const usersFor = (entityId: string): MatterLockUser[] => {
|
||||
let users = lockUsers.get(entityId);
|
||||
if (!users) {
|
||||
users = initialLockUsers();
|
||||
lockUsers.set(entityId, users);
|
||||
}
|
||||
return users;
|
||||
};
|
||||
|
||||
// Lowest free slot, the way a lock hands out user and credential indexes.
|
||||
const nextFreeIndex = (taken: number[]): number => {
|
||||
let index = 1;
|
||||
while (taken.includes(index)) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
export const mockMatter = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("matter/network_topology", () => buildTopology());
|
||||
|
||||
hass.mockWS("matter/subscribe_network_topology", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.(buildTopology()))
|
||||
);
|
||||
|
||||
hass.mockWS("matter/node_diagnostics", (msg: { device_id: string }) => {
|
||||
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
|
||||
return node ? buildNodeDiagnostics(node) : nodeNotFound(msg.device_id);
|
||||
});
|
||||
|
||||
hass.mockWS("matter/ping_node", (msg: { device_id: string }) => {
|
||||
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
|
||||
return node
|
||||
? { [nodeIpAddress(node)]: node.available !== false }
|
||||
: nodeNotFound(msg.device_id);
|
||||
});
|
||||
|
||||
hass.mockWS("matter/interview_node", () => undefined);
|
||||
|
||||
// Actions the device page offers for an available node. Without these the
|
||||
// dialogs behind them fail with `command_not_mocked`.
|
||||
hass.mockWS(
|
||||
"matter/open_commissioning_window",
|
||||
() => COMMISSIONING_PARAMETERS
|
||||
);
|
||||
hass.mockWS("matter/remove_matter_fabric", () => undefined);
|
||||
hass.mockWS("matter/set_wifi_credentials", () => undefined);
|
||||
hass.mockWS("matter/set_thread", () => undefined);
|
||||
|
||||
// The lock device exposes "Manage lock", whose dialog reads back the response
|
||||
// of these services.
|
||||
hass.mockService("matter", "get_lock_info", (_data, target) => ({
|
||||
[target!.entity_id]: LOCK_INFO,
|
||||
}));
|
||||
hass.mockService("matter", "get_lock_users", (_data, target) => ({
|
||||
// Copied, the way a real response would be: the dialog assigns the list to
|
||||
// reactive state, so handing back the same array leaves it unchanged and
|
||||
// the list never rerenders.
|
||||
[target!.entity_id]: {
|
||||
max_users: LOCK_INFO.max_users!,
|
||||
users: usersFor(target!.entity_id).map((user) => ({
|
||||
...user,
|
||||
credentials: user.credentials.map((credential) => ({ ...credential })),
|
||||
})),
|
||||
} satisfies MatterLockUsersResponse,
|
||||
}));
|
||||
|
||||
// Renames the user the credential below created, or edits an existing one.
|
||||
hass.mockService("matter", "set_lock_user", (data, target) => {
|
||||
const user = usersFor(target!.entity_id).find(
|
||||
(candidate) => candidate.user_index === data?.user_index
|
||||
);
|
||||
if (user) {
|
||||
if (data?.user_name !== undefined) {
|
||||
user.user_name = data.user_name;
|
||||
}
|
||||
if (data?.user_type !== undefined) {
|
||||
user.user_type = data.user_type;
|
||||
}
|
||||
if (data?.credential_rule !== undefined) {
|
||||
user.credential_rule = data.credential_rule;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
hass.mockService("matter", "clear_lock_user", (data, target) => {
|
||||
const users = usersFor(target!.entity_id);
|
||||
const index = users.findIndex(
|
||||
(candidate) => candidate.user_index === data?.user_index
|
||||
);
|
||||
if (index !== -1) {
|
||||
users.splice(index, 1);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// Adding a user starts here: the credential creates it, and the dialog reads
|
||||
// the assigned index straight back to name it.
|
||||
hass.mockService("matter", "set_lock_credential", (data, target) => {
|
||||
const users = usersFor(target!.entity_id);
|
||||
const userIndex =
|
||||
(data?.user_index as number | null | undefined) ??
|
||||
nextFreeIndex(
|
||||
users
|
||||
.map((user) => user.user_index)
|
||||
.filter((i): i is number => i !== null)
|
||||
);
|
||||
const credentialIndex =
|
||||
(data?.credential_index as number | null | undefined) ??
|
||||
nextFreeIndex(
|
||||
users.flatMap((user) =>
|
||||
user.credentials
|
||||
.map((credential) => credential.index)
|
||||
.filter((i): i is number => i !== null)
|
||||
)
|
||||
);
|
||||
const credential = {
|
||||
type: (data?.credential_type as string) ?? "pin",
|
||||
index: credentialIndex,
|
||||
};
|
||||
const user = users.find((candidate) => candidate.user_index === userIndex);
|
||||
if (user) {
|
||||
user.credentials = [...user.credentials, credential];
|
||||
} else {
|
||||
users.push({
|
||||
user_index: userIndex,
|
||||
user_name: null,
|
||||
user_unique_id: userIndex,
|
||||
user_status: data?.user_status ?? "occupied_enabled",
|
||||
user_type: data?.user_type ?? "unrestricted_user",
|
||||
credential_rule: "single",
|
||||
credentials: [credential],
|
||||
next_user_index: null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
[target!.entity_id]: {
|
||||
credential_index: credentialIndex,
|
||||
user_index: userIndex,
|
||||
next_credential_index: null,
|
||||
} satisfies SetMatterLockCredentialResult,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import { configEntry } from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const FLEXIT_ENTRY_ID = "mock-flexit";
|
||||
export const SOLAREDGE_ENTRY_ID = "mock-solaredge";
|
||||
export const FRONIUS_ENTRY_ID = "mock-fronius";
|
||||
|
||||
/** The RS-485 adapter the two serial devices share, as the Serial panel lists it. */
|
||||
export const RS485_PORT = "/dev/ttyUSB1";
|
||||
|
||||
export const modbusFixtures: ConnectivityFixtures = {
|
||||
components: ["modbus"],
|
||||
commands: ["modbus/"],
|
||||
manifests: [
|
||||
manifest("flexit", "Flexit", {
|
||||
integration_type: "device",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("solaredge_modbus", "SolarEdge Modbus", {
|
||||
integration_type: "device",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("fronius", "Fronius", {
|
||||
integration_type: "device",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "device",
|
||||
entry: configEntry(FLEXIT_ENTRY_ID, "flexit", "Flexit Nordic S4"),
|
||||
},
|
||||
{
|
||||
type: "device",
|
||||
entry: configEntry(
|
||||
SOLAREDGE_ENTRY_ID,
|
||||
"solaredge_modbus",
|
||||
"SolarEdge SE7K"
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "device",
|
||||
entry: configEntry(FRONIUS_ENTRY_ID, "fronius", "Fronius Symo"),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ModbusConnection } from "../../../../../src/data/modbus";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
FLEXIT_ENTRY_ID,
|
||||
FRONIUS_ENTRY_ID,
|
||||
RS485_PORT,
|
||||
SOLAREDGE_ENTRY_ID,
|
||||
} from "./fixtures";
|
||||
|
||||
const CONNECTIONS: ModbusConnection[] = [
|
||||
// Two integrations on one RS-485 bus, which is what the panel exists to show
|
||||
{
|
||||
endpoint: ["serial", RS485_PORT],
|
||||
connected: true,
|
||||
source: "config_entry",
|
||||
units: { [FLEXIT_ENTRY_ID]: [1], [SOLAREDGE_ENTRY_ID]: [2] },
|
||||
},
|
||||
{
|
||||
endpoint: ["tcp", "192.168.1.42", 502],
|
||||
connected: true,
|
||||
source: "config_entry",
|
||||
units: { [FRONIUS_ENTRY_ID]: [1] },
|
||||
},
|
||||
// A second inverter behind a gateway that is not answering
|
||||
{
|
||||
endpoint: ["tcp", "modbus-gateway.local", 502],
|
||||
connected: false,
|
||||
source: "config_entry",
|
||||
units: { [FRONIUS_ENTRY_ID]: [2] },
|
||||
},
|
||||
// A YAML hub keeps a link of its own to a device an integration also talks to
|
||||
{
|
||||
endpoint: ["tcp", "192.168.1.42", 502],
|
||||
connected: true,
|
||||
source: "yaml",
|
||||
units: { modbus_hub: [10, 11] },
|
||||
},
|
||||
];
|
||||
|
||||
export const mockModbus = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("modbus/connections/list", () => ({ connections: CONNECTIONS }));
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-mqtt";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"mqtt-fridge-sensor",
|
||||
"Fridge sensor",
|
||||
"Xiaomi",
|
||||
"LYWSD03MMC",
|
||||
ENTRY_ID,
|
||||
{ area_id: "kitchen" }
|
||||
),
|
||||
device(
|
||||
"mqtt-garage-door",
|
||||
"Garage door",
|
||||
"Shelly",
|
||||
"Shelly Plus 1",
|
||||
ENTRY_ID
|
||||
),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"sensor.fridge_temperature",
|
||||
"mqtt-fridge-sensor",
|
||||
ENTRY_ID,
|
||||
"mqtt"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.fridge_battery",
|
||||
"mqtt-fridge-sensor",
|
||||
ENTRY_ID,
|
||||
"mqtt"
|
||||
),
|
||||
registryEntry("cover.garage_door", "mqtt-garage-door", ENTRY_ID, "mqtt"),
|
||||
];
|
||||
|
||||
export const mqttFixtures: ConnectivityFixtures = {
|
||||
components: ["mqtt"],
|
||||
// `execute_script` too: the panel publishes through a script action.
|
||||
commands: ["mqtt/", "execute_script"],
|
||||
manifests: [manifest("mqtt", "MQTT", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "mqtt", "core-mosquitto", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"sensor.fridge_temperature": {
|
||||
entity_id: "sensor.fridge_temperature",
|
||||
state: "21.4",
|
||||
attributes: {
|
||||
friendly_name: "Fridge temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"sensor.fridge_battery": {
|
||||
entity_id: "sensor.fridge_battery",
|
||||
state: "92",
|
||||
attributes: {
|
||||
friendly_name: "Fridge battery",
|
||||
device_class: "battery",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"cover.garage_door": {
|
||||
entity_id: "cover.garage_door",
|
||||
state: "closed",
|
||||
attributes: { friendly_name: "Garage door", device_class: "garage" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,192 +0,0 @@
|
||||
import type {
|
||||
MQTTDeviceDebugInfo,
|
||||
MQTTMessage,
|
||||
} from "../../../../../src/data/mqtt";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const PAYLOADS: Record<string, () => string> = {
|
||||
"homeassistant/status": () => "online",
|
||||
"zigbee2mqtt/bridge/state": () => '{"state":"online"}',
|
||||
default: () =>
|
||||
JSON.stringify({
|
||||
battery: 92,
|
||||
linkquality: 120,
|
||||
// Built from whole tenths, so the payload never carries the noise a
|
||||
// float sum leaves behind, like 21.599999999999998.
|
||||
temperature: (214 + Math.floor(Math.random() * 11)) / 10,
|
||||
}),
|
||||
};
|
||||
|
||||
// Subscriptions take a topic filter, but a message carries the topic it was
|
||||
// actually published on, so a filter has to be resolved to one concrete topic
|
||||
// before it can be echoed back.
|
||||
const resolveFilter = (filter: string): string =>
|
||||
filter
|
||||
.split("/")
|
||||
.flatMap((level) => {
|
||||
if (level === "+") {
|
||||
return ["kitchen"];
|
||||
}
|
||||
if (level === "#") {
|
||||
return ["kitchen", "temperature"];
|
||||
}
|
||||
return [level];
|
||||
})
|
||||
.join("/") || "homeassistant/status";
|
||||
|
||||
const buildMessage = (topic: string, qos: number): MQTTMessage => ({
|
||||
topic,
|
||||
payload: (PAYLOADS[topic] ?? PAYLOADS.default)(),
|
||||
qos,
|
||||
retain: 0,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// A filter matches a topic level by level: "+" stands for one level, "#" for
|
||||
// the rest of them.
|
||||
const filterMatches = (filter: string, topic: string): boolean => {
|
||||
const filterLevels = filter.split("/");
|
||||
const topicLevels = topic.split("/");
|
||||
for (let index = 0; index < filterLevels.length; index += 1) {
|
||||
if (filterLevels[index] === "#") {
|
||||
return true;
|
||||
}
|
||||
if (index >= topicLevels.length) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filterLevels[index] !== "+" &&
|
||||
filterLevels[index] !== topicLevels[index]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return filterLevels.length === topicLevels.length;
|
||||
};
|
||||
|
||||
// The panel's listen card and its publish button talk to each other through
|
||||
// the broker, so the mock keeps the subscriptions and delivers to them.
|
||||
const subscriptions = new Set<{
|
||||
filter: string;
|
||||
qos: number;
|
||||
deliver: (message: MQTTMessage) => void;
|
||||
}>();
|
||||
|
||||
const topicDebug = (topic: string) => ({
|
||||
topic,
|
||||
messages: [buildMessage(topic, 0)],
|
||||
});
|
||||
|
||||
// Keyed by device, the way the backend builds this per requested device.
|
||||
const DEBUG_INFO: Record<string, MQTTDeviceDebugInfo> = {
|
||||
"mqtt-fridge-sensor": {
|
||||
entities: [
|
||||
{
|
||||
entity_id: "sensor.fridge_temperature",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/sensor/fridge/temperature/config",
|
||||
payload: {
|
||||
name: "Temperature",
|
||||
state_topic: "zigbee2mqtt/fridge",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
|
||||
transmitted: [],
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.fridge_battery",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/sensor/fridge/battery/config",
|
||||
payload: {
|
||||
name: "Battery",
|
||||
state_topic: "zigbee2mqtt/fridge",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
|
||||
transmitted: [],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
"mqtt-garage-door": {
|
||||
entities: [
|
||||
{
|
||||
entity_id: "cover.garage_door",
|
||||
discovery_data: {
|
||||
topic: "homeassistant/cover/garage/config",
|
||||
payload: {
|
||||
name: "Garage door",
|
||||
state_topic: "shellyplus1/status/cover:0",
|
||||
command_topic: "shellyplus1/command/cover:0",
|
||||
device_class: "garage",
|
||||
},
|
||||
},
|
||||
subscriptions: [topicDebug("shellyplus1/status/cover:0")],
|
||||
transmitted: [topicDebug("shellyplus1/command/cover:0")],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const mockMqtt = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"mqtt/subscribe",
|
||||
(msg: { topic: string; qos?: number }, _hass, onChange) => {
|
||||
// Echo a message on the subscribed topic every few seconds so the
|
||||
// listen card in the MQTT panel shows traffic.
|
||||
const qos = msg.qos ?? 0;
|
||||
const topic = resolveFilter(msg.topic);
|
||||
const deliver = (message: MQTTMessage) => onChange?.(message);
|
||||
const subscription = { filter: msg.topic, qos, deliver };
|
||||
subscriptions.add(subscription);
|
||||
const send = () => deliver(buildMessage(topic, qos));
|
||||
const stopInitial = emitInitial(send);
|
||||
const interval = window.setInterval(send, 3000);
|
||||
return () => {
|
||||
stopInitial();
|
||||
clearInterval(interval);
|
||||
subscriptions.delete(subscription);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// The panel publishes through a script action rather than a `mqtt/` command,
|
||||
// so without this the publish button only ever reports a failure. Delivering
|
||||
// to the matching subscriptions is what makes the two halves of the panel
|
||||
// work together.
|
||||
hass.mockWS(
|
||||
"execute_script",
|
||||
(msg: { sequence: { action?: string; data?: Record<string, any> }[] }) => {
|
||||
msg.sequence
|
||||
?.filter((action) => action.action === "mqtt.publish")
|
||||
.forEach((action) => {
|
||||
const topic = String(action.data?.topic ?? "");
|
||||
const message: MQTTMessage = {
|
||||
topic,
|
||||
payload: String(action.data?.payload ?? ""),
|
||||
qos: Number(action.data?.qos ?? 0),
|
||||
retain: action.data?.retain ? 1 : 0,
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
subscriptions.forEach((subscription) => {
|
||||
if (filterMatches(subscription.filter, topic)) {
|
||||
subscription.deliver(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { context: { id: "mock-context" }, response: {} };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"mqtt/device/debug_info",
|
||||
(msg: { device_id: string }): MQTTDeviceDebugInfo =>
|
||||
DEBUG_INFO[msg.device_id] ?? { entities: [], triggers: [] }
|
||||
);
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
minutesAgo,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-rf-bridge";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"rf-bridge-garage",
|
||||
"Garage bridge",
|
||||
"Sonoff",
|
||||
"RF Bridge R2",
|
||||
ENTRY_ID,
|
||||
{ area_id: "garden" }
|
||||
),
|
||||
device("rf-bridge-shed", "Shed bridge", "Sonoff", "RF Bridge R2", ENTRY_ID),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"radio_frequency.garage_bridge",
|
||||
"rf-bridge-garage",
|
||||
ENTRY_ID,
|
||||
"esphome",
|
||||
"Transceiver"
|
||||
),
|
||||
registryEntry(
|
||||
"radio_frequency.shed_bridge",
|
||||
"rf-bridge-shed",
|
||||
ENTRY_ID,
|
||||
"esphome",
|
||||
"Transceiver"
|
||||
),
|
||||
];
|
||||
|
||||
export const radioFrequencyFixtures: ConnectivityFixtures = {
|
||||
components: ["radio_frequency"],
|
||||
commands: ["radio_frequency/"],
|
||||
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
|
||||
configEntries: [
|
||||
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"radio_frequency.garage_bridge": {
|
||||
entity_id: "radio_frequency.garage_bridge",
|
||||
state: minutesAgo(47),
|
||||
attributes: { friendly_name: "Garage bridge Transceiver" },
|
||||
},
|
||||
"radio_frequency.shed_bridge": {
|
||||
entity_id: "radio_frequency.shed_bridge",
|
||||
state: "unknown",
|
||||
attributes: { friendly_name: "Shed bridge Transceiver" },
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
entity_component: {
|
||||
"component.radio_frequency.entity_component._.name": "Transceiver",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { RadioFrequencyTransmitter } from "../../../../../src/data/radio_frequency";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
const TRANSMITTERS: RadioFrequencyTransmitter[] = [
|
||||
{
|
||||
entity_id: "radio_frequency.garage_bridge",
|
||||
device_id: "rf-bridge-garage",
|
||||
config_entry_id: "mock-rf-bridge",
|
||||
supported_frequency_ranges: [[433920000, 433920000]],
|
||||
supported_modulations: ["OOK"],
|
||||
},
|
||||
{
|
||||
entity_id: "radio_frequency.shed_bridge",
|
||||
device_id: "rf-bridge-shed",
|
||||
config_entry_id: "mock-rf-bridge",
|
||||
supported_frequency_ranges: [[433920000, 433920000]],
|
||||
supported_modulations: ["OOK"],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockRadioFrequency = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("radio_frequency/list", () => ({
|
||||
transmitters: TRANSMITTERS,
|
||||
}));
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const serialFixtures: ConnectivityFixtures = {
|
||||
components: ["usb"],
|
||||
commands: ["usb/"],
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { SerialPortUsage } from "../../../../../src/data/usb";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
// The RS-485 port below carries the Modbus connections, so both sides name the
|
||||
// same config entries.
|
||||
import {
|
||||
FLEXIT_ENTRY_ID,
|
||||
RS485_PORT,
|
||||
SOLAREDGE_ENTRY_ID,
|
||||
} from "../modbus/fixtures";
|
||||
|
||||
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: RS485_PORT,
|
||||
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: [
|
||||
{
|
||||
kind: "config_entry",
|
||||
title: "Flexit Nordic S4",
|
||||
active: true,
|
||||
domain: "flexit",
|
||||
config_entry_id: FLEXIT_ENTRY_ID,
|
||||
slug: null,
|
||||
},
|
||||
{
|
||||
kind: "config_entry",
|
||||
title: "SolarEdge SE7K",
|
||||
active: true,
|
||||
domain: "solaredge_modbus",
|
||||
config_entry_id: SOLAREDGE_ENTRY_ID,
|
||||
slug: null,
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
// A port that is configured but not currently plugged in. Its add-on icon
|
||||
// is requested straight from /api/hassio/addons/<slug>/icon, which the
|
||||
// demo has no backend for, so the icon stays blank here.
|
||||
device: "/dev/ttyUSB2",
|
||||
resolved_device: null,
|
||||
serial_number: "0001",
|
||||
manufacturer: "Silicon Labs",
|
||||
description: "CP2102 USB to UART Bridge Controller",
|
||||
interface_description: null,
|
||||
interface_num: 0,
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
bcd_device: null,
|
||||
matching_integrations: [],
|
||||
present: false,
|
||||
consumers: [
|
||||
{
|
||||
kind: "app",
|
||||
title: "ESPHome Device Builder",
|
||||
active: false,
|
||||
domain: null,
|
||||
config_entry_id: null,
|
||||
slug: "esphome",
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSerial = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("usb/list_serial_ports", () => PORTS);
|
||||
hass.mockWS("usb/scan", () => undefined);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
// Mocked WebSocket subscriptions are registered synchronously, so a callback
|
||||
// invoked straight away can land before the subscriber is ready for it: pages
|
||||
// that ignore messages received before their first render drop it, and
|
||||
// `createCollection` overwrites it with the empty initial fetch. Emitting the
|
||||
// first message from a timeout matches the real backend, which always answers
|
||||
// asynchronously.
|
||||
export const emitInitial = (send: () => void): (() => void) => {
|
||||
const timeout = window.setTimeout(send, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const tagsFixtures: ConnectivityFixtures = {
|
||||
components: ["tag"],
|
||||
commands: ["tag/"],
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import { configEntry } from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const THREAD_ENTRY_ID = "mock-thread";
|
||||
const OTBR_ENTRY_ID = "mock-otbr";
|
||||
|
||||
export const threadFixtures: ConnectivityFixtures = {
|
||||
components: ["thread", "otbr"],
|
||||
commands: ["thread/", "otbr/"],
|
||||
manifests: [
|
||||
manifest("thread", "Thread", {
|
||||
integration_type: "service",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("otbr", "Open Thread Border Router", {
|
||||
integration_type: "service",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "service",
|
||||
entry: configEntry(THREAD_ENTRY_ID, "thread", "Thread"),
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: configEntry(OTBR_ENTRY_ID, "otbr", "Open Thread Border Router"),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,441 +0,0 @@
|
||||
import type { OTBRInfoDict } from "../../../../../src/data/otbr";
|
||||
import type {
|
||||
ThreadDataSet,
|
||||
ThreadRouter,
|
||||
} from "../../../../../src/data/thread";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
const HA_EXT_PAN_ID = "DEAD00BEEF00CAFE";
|
||||
const AMAZON_EXT_PAN_ID = "0011223344556677";
|
||||
|
||||
const OTBR_EXT_ADDRESS = "f6a1c30d2b4e5f61";
|
||||
const OTBR_BORDER_AGENT_ID = "230c6a1ac57f6f4be262acf32e5ef52c";
|
||||
|
||||
const ROUTERS: ThreadRouter[] = [
|
||||
{
|
||||
instance_name: "HomeAssistant OpenThreadBorderRouter",
|
||||
addresses: ["192.168.1.10"],
|
||||
border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
brand: "homeassistant",
|
||||
extended_address: OTBR_EXT_ADDRESS,
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "OpenThread Border Router",
|
||||
network_name: "ha-thread",
|
||||
server: "core-openthread-border-router.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Home Assistant",
|
||||
},
|
||||
{
|
||||
instance_name: "HomePod mini",
|
||||
addresses: ["192.168.1.24"],
|
||||
border_agent_id: "6a1ac57f6f4be262acf32e5ef52c230c",
|
||||
brand: "apple",
|
||||
extended_address: "aabbccddeeff0011",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "HomePod mini",
|
||||
network_name: "ha-thread",
|
||||
server: "homepod-mini.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Apple Inc.",
|
||||
},
|
||||
{
|
||||
instance_name: "Nest Hub",
|
||||
addresses: ["192.168.1.31"],
|
||||
border_agent_id: "ac57f6f4be262acf32e5ef52c230c6a1",
|
||||
brand: "google",
|
||||
extended_address: "bbccddeeff001122",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
model_name: "Google Nest Hub",
|
||||
network_name: "ha-thread",
|
||||
server: "nest-hub.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Google Inc.",
|
||||
},
|
||||
{
|
||||
instance_name: "Echo (4th Gen)",
|
||||
addresses: ["192.168.1.42"],
|
||||
border_agent_id: "57f6f4be262acf32e5ef52c230c6a1ac",
|
||||
brand: "amazon",
|
||||
extended_address: "ccddeeff00112233",
|
||||
extended_pan_id: AMAZON_EXT_PAN_ID,
|
||||
model_name: "Echo",
|
||||
network_name: "AmazonThread",
|
||||
server: "amazon-echo.local.",
|
||||
thread_version: "1.3.0",
|
||||
unconfigured: null,
|
||||
vendor_name: "Amazon",
|
||||
},
|
||||
];
|
||||
|
||||
const decodeUtf8 = (value: string): string | undefined => {
|
||||
const bytes = Uint8Array.from(
|
||||
(value.match(/../g) ?? []).map((byte) => parseInt(byte, 16))
|
||||
);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const tlv = (type: number, value: string) =>
|
||||
type.toString(16).padStart(2, "0").toUpperCase() +
|
||||
(value.length / 2).toString(16).padStart(2, "0").toUpperCase() +
|
||||
value.toUpperCase();
|
||||
|
||||
const textToHex = (text: string) =>
|
||||
Array.from(text)
|
||||
.map((character) => character.charCodeAt(0).toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const replaceTlvField = (value: string, type: number, replacement: string) => {
|
||||
let index = 0;
|
||||
let out = "";
|
||||
let replaced = false;
|
||||
while (index + 4 <= value.length) {
|
||||
const fieldType = parseInt(value.slice(index, index + 2), 16);
|
||||
const length = parseInt(value.slice(index + 2, index + 4), 16);
|
||||
const end = index + 4 + length * 2;
|
||||
out +=
|
||||
fieldType === type ? tlv(type, replacement) : value.slice(index, end);
|
||||
replaced = replaced || fieldType === type;
|
||||
index = end;
|
||||
}
|
||||
return replaced ? out : out + tlv(type, replacement);
|
||||
};
|
||||
|
||||
const buildDatasetTlv = (dataset: ThreadDataSet) =>
|
||||
[
|
||||
tlv(0x0e, "0000000000010000"),
|
||||
tlv(0x00, `00${(dataset.channel ?? 15).toString(16).padStart(4, "0")}`),
|
||||
tlv(0x35, "000040010200"),
|
||||
tlv(0x02, dataset.extended_pan_id),
|
||||
tlv(0x07, "FD11220000000000"),
|
||||
tlv(0x05, "00112233445566778899AABBCCDDEEFF"),
|
||||
dataset.network_name === null
|
||||
? ""
|
||||
: tlv(0x03, textToHex(dataset.network_name)),
|
||||
tlv(0x01, (dataset.pan_id ?? "1234").padStart(4, "0")),
|
||||
tlv(0x04, "1035060004001FFFE00C0402A0F7F800"),
|
||||
tlv(0x0c, "02A0F7F8"),
|
||||
].join("");
|
||||
|
||||
const parseDatasetTlv = (value: string) => {
|
||||
if (value.length % 2 !== 0 || !/^[0-9A-Fa-f]*$/.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const fields = new Map<number, string>();
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
if (index + 4 > value.length) {
|
||||
return undefined;
|
||||
}
|
||||
const type = parseInt(value.slice(index, index + 2), 16);
|
||||
const length = parseInt(value.slice(index + 2, index + 4), 16);
|
||||
const start = index + 4;
|
||||
const end = start + length * 2;
|
||||
if (end > value.length) {
|
||||
return undefined;
|
||||
}
|
||||
if (fields.has(type)) {
|
||||
return undefined;
|
||||
}
|
||||
fields.set(type, value.slice(start, end).toUpperCase());
|
||||
index = end;
|
||||
}
|
||||
const extendedPanId = fields.get(0x02);
|
||||
const networkName = fields.get(0x03);
|
||||
const activeTimestamp = fields.get(0x0e);
|
||||
if (
|
||||
!extendedPanId ||
|
||||
extendedPanId.length !== 16 ||
|
||||
!activeTimestamp ||
|
||||
activeTimestamp.length !== 16
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const decodedName =
|
||||
networkName === undefined ? null : decodeUtf8(networkName);
|
||||
if (decodedName === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = fields.get(0x00);
|
||||
const channelNumber = channel ? parseInt(channel.slice(2), 16) : null;
|
||||
if (channelNumber === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
activeTimestamp,
|
||||
extendedPanId,
|
||||
networkName: decodedName,
|
||||
panId: fields.get(0x01) ?? null,
|
||||
channel: channelNumber,
|
||||
};
|
||||
};
|
||||
|
||||
const DATASETS: ThreadDataSet[] = [
|
||||
{
|
||||
channel: 15,
|
||||
created: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
dataset_id: "ha-thread-dataset",
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
network_name: "ha-thread",
|
||||
pan_id: "1234",
|
||||
preferred_border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
preferred_extended_address: OTBR_EXT_ADDRESS,
|
||||
preferred: true,
|
||||
source: "otbr",
|
||||
},
|
||||
];
|
||||
|
||||
const DATASET_TLVS: Record<string, string> = Object.fromEntries(
|
||||
DATASETS.map((dataset) => [dataset.dataset_id, buildDatasetTlv(dataset)])
|
||||
);
|
||||
|
||||
const OTBR_INFO: OTBRInfoDict = {
|
||||
[OTBR_EXT_ADDRESS]: {
|
||||
active_dataset_tlvs: DATASET_TLVS["ha-thread-dataset"],
|
||||
border_agent_id: OTBR_BORDER_AGENT_ID,
|
||||
channel: 15,
|
||||
extended_address: OTBR_EXT_ADDRESS,
|
||||
extended_pan_id: HA_EXT_PAN_ID,
|
||||
url: "http://core-openthread-border-router:8081",
|
||||
},
|
||||
};
|
||||
|
||||
let added = 0;
|
||||
let created = 0;
|
||||
|
||||
const randomExtendedPanId = () =>
|
||||
Array.from({ length: 8 }, () =>
|
||||
Math.floor(Math.random() * 256)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const moveRouter = (
|
||||
extendedAddress: string,
|
||||
extendedPanId: string,
|
||||
networkName: string | null,
|
||||
datasetId: string
|
||||
) => {
|
||||
const info = OTBR_INFO[extendedAddress];
|
||||
const moved = DATASETS.find((item) => item.dataset_id === datasetId);
|
||||
if (info) {
|
||||
info.extended_pan_id = extendedPanId;
|
||||
info.active_dataset_tlvs =
|
||||
DATASET_TLVS[datasetId] ?? info.active_dataset_tlvs;
|
||||
info.channel = moved?.channel ?? info.channel;
|
||||
}
|
||||
const router = ROUTERS.find(
|
||||
(candidate) => candidate.extended_address === extendedAddress
|
||||
);
|
||||
if (router) {
|
||||
router.extended_pan_id = extendedPanId;
|
||||
router.network_name = networkName;
|
||||
announce(router);
|
||||
}
|
||||
};
|
||||
|
||||
type RouterListener = (event: {
|
||||
key: string;
|
||||
type: "router_discovered" | "router_removed";
|
||||
data: ThreadRouter;
|
||||
}) => void;
|
||||
|
||||
const listeners = new Set<RouterListener>();
|
||||
|
||||
const announce = (router: ThreadRouter) =>
|
||||
listeners.forEach((listener) =>
|
||||
listener({
|
||||
key: router.extended_address,
|
||||
type: "router_discovered",
|
||||
data: router,
|
||||
})
|
||||
);
|
||||
|
||||
export const mockThread = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("thread/discover_routers", (_msg, _hass, onChange) => {
|
||||
const listener = onChange as RouterListener | undefined;
|
||||
if (listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
const stopInitial = emitInitial(() => ROUTERS.forEach(announce));
|
||||
return () => {
|
||||
stopInitial();
|
||||
if (listener) {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
hass.mockWS("thread/list_datasets", () => ({
|
||||
datasets: DATASETS.map((dataset) => ({ ...dataset })),
|
||||
}));
|
||||
hass.mockWS("thread/get_dataset_tlv", (msg: { dataset_id: string }) => {
|
||||
const value = DATASET_TLVS[msg.dataset_id];
|
||||
if (!value) {
|
||||
throw new Error(`Dataset ${msg.dataset_id} not found`);
|
||||
}
|
||||
return { tlv: value };
|
||||
});
|
||||
|
||||
hass.mockWS("otbr/info", () => OTBR_INFO);
|
||||
|
||||
hass.mockWS(
|
||||
"thread/add_dataset_tlv",
|
||||
(msg: { source: string; tlv: string }) => {
|
||||
const parsed = parseDatasetTlv(msg.tlv);
|
||||
if (!parsed) {
|
||||
throw new Error("Invalid dataset");
|
||||
}
|
||||
const existing = DATASETS.find(
|
||||
(candidate) => candidate.extended_pan_id === parsed.extendedPanId
|
||||
);
|
||||
if (existing) {
|
||||
const current = parseDatasetTlv(DATASET_TLVS[existing.dataset_id]);
|
||||
if (current && parsed.activeTimestamp <= current.activeTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
existing.channel = parsed.channel;
|
||||
existing.network_name = parsed.networkName;
|
||||
existing.pan_id = parsed.panId;
|
||||
DATASET_TLVS[existing.dataset_id] = msg.tlv.toUpperCase();
|
||||
return undefined;
|
||||
}
|
||||
added += 1;
|
||||
const dataset: ThreadDataSet = {
|
||||
channel: parsed.channel,
|
||||
created: new Date().toISOString(),
|
||||
dataset_id: `added-dataset-${added}`,
|
||||
extended_pan_id: parsed.extendedPanId,
|
||||
network_name: parsed.networkName,
|
||||
pan_id: parsed.panId,
|
||||
preferred_border_agent_id: null,
|
||||
preferred_extended_address: null,
|
||||
preferred: false,
|
||||
source: msg.source,
|
||||
};
|
||||
DATASETS.push(dataset);
|
||||
DATASET_TLVS[dataset.dataset_id] = msg.tlv.toUpperCase();
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("thread/delete_dataset", (msg: { dataset_id: string }) => {
|
||||
const index = DATASETS.findIndex(
|
||||
(dataset) => dataset.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (index === -1) {
|
||||
throw new Error(`Dataset ${msg.dataset_id} not found`);
|
||||
}
|
||||
if (DATASETS[index].preferred) {
|
||||
throw new Error("Preferred dataset cannot be deleted");
|
||||
}
|
||||
DATASETS.splice(index, 1);
|
||||
delete DATASET_TLVS[msg.dataset_id];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS("thread/set_preferred_dataset", (msg: { dataset_id: string }) => {
|
||||
DATASETS.forEach((dataset) => {
|
||||
dataset.preferred = dataset.dataset_id === msg.dataset_id;
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"thread/set_preferred_border_agent",
|
||||
(msg: {
|
||||
dataset_id: string;
|
||||
border_agent_id: string | null;
|
||||
extended_address: string;
|
||||
}) => {
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (dataset) {
|
||||
dataset.preferred_border_agent_id = msg.border_agent_id;
|
||||
dataset.preferred_extended_address = msg.extended_address;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("otbr/create_network", (msg: { extended_address: string }) => {
|
||||
created += 1;
|
||||
const dataset: ThreadDataSet = {
|
||||
channel: 15,
|
||||
created: new Date().toISOString(),
|
||||
dataset_id: `created-dataset-${created}`,
|
||||
extended_pan_id: randomExtendedPanId(),
|
||||
network_name: `ha-thread-${created}`,
|
||||
pan_id: "1234",
|
||||
preferred_border_agent_id: null,
|
||||
preferred_extended_address: null,
|
||||
preferred: false,
|
||||
source: "otbr",
|
||||
};
|
||||
DATASETS.push(dataset);
|
||||
DATASET_TLVS[dataset.dataset_id] = buildDatasetTlv(dataset);
|
||||
moveRouter(
|
||||
msg.extended_address,
|
||||
dataset.extended_pan_id,
|
||||
dataset.network_name,
|
||||
dataset.dataset_id
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"otbr/set_network",
|
||||
(msg: { extended_address: string; dataset_id: string }) => {
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.dataset_id === msg.dataset_id
|
||||
);
|
||||
if (dataset) {
|
||||
const info = OTBR_INFO[msg.extended_address];
|
||||
if (info) {
|
||||
info.channel = dataset.channel ?? info.channel;
|
||||
}
|
||||
moveRouter(
|
||||
msg.extended_address,
|
||||
dataset.extended_pan_id,
|
||||
dataset.network_name,
|
||||
dataset.dataset_id
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"otbr/set_channel",
|
||||
(msg: { extended_address: string; channel: number }) => {
|
||||
const info = OTBR_INFO[msg.extended_address];
|
||||
if (info) {
|
||||
info.channel = msg.channel;
|
||||
const dataset = DATASETS.find(
|
||||
(candidate) => candidate.extended_pan_id === info.extended_pan_id
|
||||
);
|
||||
if (dataset) {
|
||||
dataset.channel = msg.channel;
|
||||
DATASET_TLVS[dataset.dataset_id] = replaceTlvField(
|
||||
DATASET_TLVS[dataset.dataset_id],
|
||||
0x00,
|
||||
`00${msg.channel.toString(16).padStart(4, "0")}`
|
||||
);
|
||||
info.active_dataset_tlvs = DATASET_TLVS[dataset.dataset_id];
|
||||
}
|
||||
}
|
||||
return { delay: 120 };
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { ConfigEntry } from "../../../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type {
|
||||
IntegrationManifest,
|
||||
IntegrationType,
|
||||
} from "../../../../src/data/integration";
|
||||
import type { TranslationCategory } from "../../../../src/data/translation";
|
||||
import type { EntityInput } from "../../../../src/fake_data/entities/types";
|
||||
|
||||
export interface DemoConfigEntry {
|
||||
entry: ConfigEntry;
|
||||
type: IntegrationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one connectivity integration contributes to the demo besides its
|
||||
* WebSocket mocks. This is loaded eagerly, together with the registries, so it
|
||||
* must not pull in the mocks (which are code-split into the config panel
|
||||
* chunk). Each integration owns one of these, so they stay independent.
|
||||
*/
|
||||
export interface ConnectivityFixtures {
|
||||
/** Components to load, so the integration's panel is reachable. */
|
||||
components: string[];
|
||||
/** WS command prefixes served by the integration's mock, if it has one. */
|
||||
commands?: string[];
|
||||
configEntries?: DemoConfigEntry[];
|
||||
/** Manifests for the domains above, so their integration pages open. */
|
||||
manifests?: IntegrationManifest[];
|
||||
devices?: DeviceRegistryEntry[];
|
||||
entityRegistryEntries?: EntityRegistryEntry[];
|
||||
/** States for the entities above; built lazily so timestamps stay fresh. */
|
||||
entities?: () => EntityInput[];
|
||||
/** Backend translations the panel looks up, by category. */
|
||||
backendTranslations?: Partial<
|
||||
Record<TranslationCategory, Record<string, string>>
|
||||
>;
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-zha";
|
||||
|
||||
export const COORDINATOR_IEEE = "00:12:4b:00:24:c2:e1:00";
|
||||
export const PORCH_IEEE = "84:2e:14:ff:fe:11:22:33";
|
||||
export const MOTION_IEEE = "cc:cc:cc:ff:fe:44:55:66";
|
||||
export const PLUG_IEEE = "00:15:8d:00:03:aa:bb:cc";
|
||||
export const KITCHEN_IEEE = "84:2e:14:ff:fe:aa:bb:01";
|
||||
export const LANDING_IEEE = "00:15:8d:00:03:aa:bb:02";
|
||||
export const GARAGE_IEEE = "00:15:8d:00:03:aa:bb:03";
|
||||
export const OFFICE_IEEE = "00:15:8d:00:03:aa:bb:04";
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"zha-coordinator",
|
||||
"Home Assistant Connect ZBT-1",
|
||||
"Nabu Casa",
|
||||
"Connect ZBT-1",
|
||||
ENTRY_ID,
|
||||
{
|
||||
sw_version: "7.4.4.0",
|
||||
connections: [["zigbee", COORDINATOR_IEEE]],
|
||||
identifiers: [["zha", COORDINATOR_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-porch-light",
|
||||
"Porch light",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI bulb E27 CWS 806lm",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", PORCH_IEEE]],
|
||||
identifiers: [["zha", PORCH_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-hall-motion",
|
||||
"Hall motion",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI motion sensor",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", MOTION_IEEE]],
|
||||
identifiers: [["zha", MOTION_IEEE]],
|
||||
}
|
||||
),
|
||||
device("zha-tv-plug", "TV plug", "Innr", "SP 220", ENTRY_ID, {
|
||||
area_id: "living_room",
|
||||
connections: [["zigbee", PLUG_IEEE]],
|
||||
identifiers: [["zha", PLUG_IEEE]],
|
||||
}),
|
||||
device(
|
||||
"zha-kitchen-switch",
|
||||
"Kitchen switch",
|
||||
"IKEA of Sweden",
|
||||
"TRADFRI on/off switch",
|
||||
ENTRY_ID,
|
||||
{
|
||||
area_id: "kitchen",
|
||||
connections: [["zigbee", KITCHEN_IEEE]],
|
||||
identifiers: [["zha", KITCHEN_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-landing-sensor",
|
||||
"Landing sensor",
|
||||
"Aqara",
|
||||
"WSDCGQ11LM",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", LANDING_IEEE]],
|
||||
identifiers: [["zha", LANDING_IEEE]],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"zha-garage-contact",
|
||||
"Garage contact",
|
||||
"Aqara",
|
||||
"MCCGQ11LM",
|
||||
ENTRY_ID,
|
||||
{
|
||||
connections: [["zigbee", GARAGE_IEEE]],
|
||||
identifiers: [["zha", GARAGE_IEEE]],
|
||||
}
|
||||
),
|
||||
device("zha-office-plug", "Office plug", "Innr", "SP 240", ENTRY_ID, {
|
||||
area_id: "office",
|
||||
connections: [["zigbee", OFFICE_IEEE]],
|
||||
identifiers: [["zha", OFFICE_IEEE]],
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry("light.porch", "zha-porch-light", ENTRY_ID, "zha"),
|
||||
registryEntry(
|
||||
"binary_sensor.hall_motion",
|
||||
"zha-hall-motion",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry("switch.tv_plug", "zha-tv-plug", ENTRY_ID, "zha"),
|
||||
registryEntry(
|
||||
"sensor.kitchen_switch_battery",
|
||||
"zha-kitchen-switch",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.landing_temperature",
|
||||
"zha-landing-sensor",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry(
|
||||
"binary_sensor.garage_contact",
|
||||
"zha-garage-contact",
|
||||
ENTRY_ID,
|
||||
"zha"
|
||||
),
|
||||
registryEntry("switch.office_desk", "zha-office-plug", ENTRY_ID, "zha"),
|
||||
];
|
||||
|
||||
export const AREA_BY_IEEE: Record<string, string> = Object.fromEntries(
|
||||
DEVICES.flatMap((entry) => {
|
||||
const areaId = entry.area_id;
|
||||
return areaId
|
||||
? entry.connections
|
||||
.filter(([type]) => type === "zigbee")
|
||||
.map(([, ieee]) => [ieee, areaId])
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
export const zhaFixtures: ConnectivityFixtures = {
|
||||
components: ["zha"],
|
||||
commands: ["zha/"],
|
||||
manifests: [
|
||||
manifest("zha", "Zigbee Home Automation", {
|
||||
integration_type: "hub",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "zha", "Home Assistant Connect ZBT-1", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.porch": {
|
||||
entity_id: "light.porch",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Porch light",
|
||||
supported_color_modes: ["hs"],
|
||||
},
|
||||
},
|
||||
"binary_sensor.hall_motion": {
|
||||
entity_id: "binary_sensor.hall_motion",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Hall motion", device_class: "motion" },
|
||||
},
|
||||
"switch.tv_plug": {
|
||||
entity_id: "switch.tv_plug",
|
||||
state: "unavailable",
|
||||
attributes: { friendly_name: "TV plug" },
|
||||
},
|
||||
"sensor.kitchen_switch_battery": {
|
||||
entity_id: "sensor.kitchen_switch_battery",
|
||||
state: "78",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen switch battery",
|
||||
device_class: "battery",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"sensor.landing_temperature": {
|
||||
entity_id: "sensor.landing_temperature",
|
||||
state: "19.6",
|
||||
attributes: {
|
||||
friendly_name: "Landing temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"binary_sensor.garage_contact": {
|
||||
entity_id: "binary_sensor.garage_contact",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Garage contact", device_class: "door" },
|
||||
},
|
||||
"switch.office_desk": {
|
||||
entity_id: "switch.office_desk",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Office desk" },
|
||||
},
|
||||
}),
|
||||
backendTranslations: {
|
||||
config_panel: {
|
||||
"component.zha.config_panel.zha_options.title": "Global options",
|
||||
"component.zha.config_panel.zha_options.default_light_transition":
|
||||
"Default light transition time (seconds)",
|
||||
"component.zha.config_panel.zha_options.enhanced_light_transition":
|
||||
"Enable enhanced light color/temperature transition from an off state",
|
||||
"component.zha.config_panel.zha_options.always_prefer_xy_color_mode":
|
||||
"Always prefer XY color mode",
|
||||
"component.zha.config_panel.zha_alarm_options.title": "Alarm options",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_master_code":
|
||||
"Alarm master code",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_failed_tries":
|
||||
"Failed authentication attempts before restart",
|
||||
"component.zha.config_panel.zha_alarm_options.alarm_arm_requires_code":
|
||||
"Code required for arming",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,793 +0,0 @@
|
||||
import type {
|
||||
Attribute,
|
||||
AttributeConfigurationStatus,
|
||||
Cluster,
|
||||
ClusterConfigurationEvent,
|
||||
Command,
|
||||
Neighbor,
|
||||
ReadAttributeServiceData,
|
||||
ZHAConfiguration,
|
||||
ZHADevice,
|
||||
ZHADeviceEndpoint,
|
||||
ZHAGroup,
|
||||
ZHAEntityReference,
|
||||
ZHAGroupMember,
|
||||
ZHANetworkBackup,
|
||||
ZHANetworkSettings,
|
||||
} from "../../../../../src/data/zha";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { minutesAgo } from "../helpers";
|
||||
|
||||
import {
|
||||
AREA_BY_IEEE,
|
||||
LANDING_IEEE,
|
||||
COORDINATOR_IEEE,
|
||||
GARAGE_IEEE,
|
||||
KITCHEN_IEEE,
|
||||
MOTION_IEEE,
|
||||
OFFICE_IEEE,
|
||||
PLUG_IEEE,
|
||||
PORCH_IEEE,
|
||||
} from "./fixtures";
|
||||
|
||||
const neighbor = (
|
||||
ieee: string,
|
||||
nwk: string,
|
||||
lqi: string,
|
||||
relationship: string,
|
||||
depth = "1"
|
||||
): Neighbor => ({ ieee, nwk, lqi, depth, relationship });
|
||||
|
||||
const DEVICES: ZHADevice[] = [
|
||||
{
|
||||
available: true,
|
||||
name: "Nabu Casa Connect ZBT-1",
|
||||
ieee: COORDINATOR_IEEE,
|
||||
nwk: 0x0000,
|
||||
lqi: 255,
|
||||
rssi: "0",
|
||||
last_seen: minutesAgo(0),
|
||||
manufacturer: "Nabu Casa",
|
||||
model: "Connect ZBT-1",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-coordinator",
|
||||
user_given_name: "Home Assistant Connect ZBT-1",
|
||||
power_source: "Mains",
|
||||
device_type: "Coordinator",
|
||||
active_coordinator: true,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(PORCH_IEEE, "0x1a2b", "224", "Child"),
|
||||
neighbor(PLUG_IEEE, "0x3c4d", "198", "Child"),
|
||||
neighbor(OFFICE_IEEE, "0x7a8b", "211", "Child"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI bulb E27 CWS 806lm",
|
||||
ieee: PORCH_IEEE,
|
||||
nwk: 0x1a2b,
|
||||
lqi: 224,
|
||||
rssi: "-58",
|
||||
last_seen: minutesAgo(2),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI bulb E27 CWS 806lm",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.ikea.bulb.IkeaBulb",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-porch-light",
|
||||
user_given_name: "Porch light",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(COORDINATOR_IEEE, "0x0000", "224", "Parent", "0"),
|
||||
neighbor(MOTION_IEEE, "0x5e6f", "142", "Child", "2"),
|
||||
neighbor(KITCHEN_IEEE, "0x9c0d", "186", "Sibling", "1"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI motion sensor",
|
||||
ieee: MOTION_IEEE,
|
||||
nwk: 0x5e6f,
|
||||
lqi: 142,
|
||||
rssi: "-77",
|
||||
last_seen: minutesAgo(9),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI motion sensor",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-hall-motion",
|
||||
user_given_name: "Hall motion",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: false,
|
||||
name: "Innr SP 220",
|
||||
ieee: PLUG_IEEE,
|
||||
nwk: 0x3c4d,
|
||||
lqi: 198,
|
||||
rssi: "-64",
|
||||
last_seen: minutesAgo(240),
|
||||
manufacturer: "Innr",
|
||||
model: "SP 220",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4448,
|
||||
device_reg_id: "zha-tv-plug",
|
||||
user_given_name: "TV plug",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "TRADFRI on/off switch",
|
||||
ieee: KITCHEN_IEEE,
|
||||
nwk: 0x9c0d,
|
||||
lqi: 186,
|
||||
rssi: "-69",
|
||||
last_seen: minutesAgo(4),
|
||||
manufacturer: "IKEA of Sweden",
|
||||
model: "TRADFRI on/off switch",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.ikea.onoffswitch.IkeaSwitch",
|
||||
entities: [],
|
||||
manufacturer_code: 4476,
|
||||
device_reg_id: "zha-kitchen-switch",
|
||||
user_given_name: "Kitchen switch",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Aqara temperature sensor",
|
||||
ieee: LANDING_IEEE,
|
||||
nwk: 0xab12,
|
||||
lqi: 164,
|
||||
rssi: "-74",
|
||||
last_seen: minutesAgo(6),
|
||||
manufacturer: "Aqara",
|
||||
model: "WSDCGQ11LM",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.xiaomi.aqara.weather.Weather",
|
||||
entities: [],
|
||||
manufacturer_code: 4447,
|
||||
device_reg_id: "zha-landing-sensor",
|
||||
user_given_name: "Landing sensor",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Aqara door sensor",
|
||||
ieee: GARAGE_IEEE,
|
||||
nwk: 0xcd34,
|
||||
lqi: 118,
|
||||
rssi: "-83",
|
||||
last_seen: minutesAgo(21),
|
||||
manufacturer: "Aqara",
|
||||
model: "MCCGQ11LM",
|
||||
quirk_applied: true,
|
||||
quirk_class: "zhaquirks.xiaomi.aqara.magnet.Magnet",
|
||||
entities: [],
|
||||
manufacturer_code: 4447,
|
||||
device_reg_id: "zha-garage-contact",
|
||||
user_given_name: "Garage contact",
|
||||
power_source: "Battery",
|
||||
device_type: "EndDevice",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
name: "Innr SP 240",
|
||||
ieee: OFFICE_IEEE,
|
||||
nwk: 0x7a8b,
|
||||
lqi: 211,
|
||||
rssi: "-61",
|
||||
last_seen: minutesAgo(1),
|
||||
manufacturer: "Innr",
|
||||
model: "SP 240",
|
||||
quirk_applied: false,
|
||||
quirk_class: "zigpy.device.Device",
|
||||
entities: [],
|
||||
manufacturer_code: 4448,
|
||||
device_reg_id: "zha-office-plug",
|
||||
user_given_name: "Office plug",
|
||||
power_source: "Mains",
|
||||
device_type: "Router",
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
neighbors: [
|
||||
neighbor(COORDINATOR_IEEE, "0x0000", "211", "Parent", "0"),
|
||||
neighbor(LANDING_IEEE, "0xab12", "164", "Child", "2"),
|
||||
neighbor(GARAGE_IEEE, "0xcd34", "118", "Child", "2"),
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
];
|
||||
|
||||
DEVICES.forEach((zhaDevice) => {
|
||||
zhaDevice.area_id = AREA_BY_IEEE[zhaDevice.ieee];
|
||||
});
|
||||
|
||||
const deviceByIeee = (ieee: string): ZHADevice =>
|
||||
DEVICES.find((d) => d.ieee === ieee)!;
|
||||
|
||||
const ENDPOINT_ENTITIES: Record<string, { entity_id: string; name: string }[]> =
|
||||
{
|
||||
[PORCH_IEEE]: [{ entity_id: "light.porch", name: "Porch light" }],
|
||||
[PLUG_IEEE]: [{ entity_id: "switch.tv_plug", name: "TV plug" }],
|
||||
[OFFICE_IEEE]: [{ entity_id: "switch.office_desk", name: "Office desk" }],
|
||||
};
|
||||
|
||||
const member = (ieee: string, endpointId = 1): ZHADeviceEndpoint => ({
|
||||
device: deviceByIeee(ieee),
|
||||
endpoint_id: endpointId,
|
||||
entities: (ENDPOINT_ENTITIES[ieee] ?? []).map(
|
||||
(entity) =>
|
||||
({
|
||||
...entity,
|
||||
original_name: entity.name,
|
||||
}) as ZHAEntityReference
|
||||
),
|
||||
});
|
||||
|
||||
const GROUPS: ZHAGroup[] = [
|
||||
{
|
||||
name: "Downstairs lights",
|
||||
group_id: 1,
|
||||
members: [member(PORCH_IEEE), member(OFFICE_IEEE)],
|
||||
},
|
||||
{
|
||||
name: "Outdoor lights",
|
||||
group_id: 2,
|
||||
members: [member(PORCH_IEEE)],
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGURATION: ZHAConfiguration = {
|
||||
data: {
|
||||
zha_options: {
|
||||
default_light_transition: 0,
|
||||
enhanced_light_transition: false,
|
||||
light_transitioning_flag: true,
|
||||
always_prefer_xy_color_mode: true,
|
||||
group_members_assume_state: true,
|
||||
consider_unavailable_mains: 7200,
|
||||
consider_unavailable_battery: 21600,
|
||||
},
|
||||
zha_alarm_options: {
|
||||
alarm_master_code: "1234",
|
||||
alarm_failed_tries: 3,
|
||||
alarm_arm_requires_code: false,
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
zha_options: [
|
||||
{
|
||||
name: "default_light_transition",
|
||||
required: true,
|
||||
selector: { number: { min: 0, max: 2 ** 16 / 10, step: 0.1 } },
|
||||
},
|
||||
{
|
||||
name: "enhanced_light_transition",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "always_prefer_xy_color_mode",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
zha_alarm_options: [
|
||||
{ name: "alarm_master_code", required: true, selector: { text: {} } },
|
||||
{
|
||||
name: "alarm_failed_tries",
|
||||
required: true,
|
||||
selector: { number: { min: 0, max: 2 ** 8, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "alarm_arm_requires_code",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const BACKUPS: ZHANetworkBackup[] = [];
|
||||
|
||||
const NETWORK_SETTINGS: ZHANetworkSettings = {
|
||||
radio_type: "ezsp",
|
||||
device: { path: "/dev/ttyUSB0", baudrate: 115200, flow_control: "hardware" },
|
||||
settings: {
|
||||
backup_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
node_info: {
|
||||
nwk: "0x0000",
|
||||
ieee: COORDINATOR_IEEE,
|
||||
logical_type: "coordinator",
|
||||
},
|
||||
network_info: {
|
||||
extended_pan_id: "b0:23:2f:cc:aa:11:22:33",
|
||||
pan_id: "0x1234",
|
||||
nwk_update_id: 0,
|
||||
nwk_manager_id: "0x0000",
|
||||
channel: 15,
|
||||
channel_mask: [15, 20, 25],
|
||||
security_level: 5,
|
||||
network_key: {
|
||||
key: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
tx_counter: 138234,
|
||||
rx_counter: 0,
|
||||
seq: 0,
|
||||
partner_ieee: "ff:ff:ff:ff:ff:ff:ff:ff",
|
||||
},
|
||||
tc_link_key: {
|
||||
key: "5a:69:67:42:65:65:41:6c:6c:69:61:6e:63:65:30:39",
|
||||
tx_counter: 0,
|
||||
rx_counter: 0,
|
||||
seq: 0,
|
||||
partner_ieee: COORDINATOR_IEEE,
|
||||
},
|
||||
key_table: [],
|
||||
children: [PORCH_IEEE, PLUG_IEEE, OFFICE_IEEE],
|
||||
nwk_addresses: {
|
||||
[PORCH_IEEE]: "0x1a2b",
|
||||
[MOTION_IEEE]: "0x5e6f",
|
||||
[PLUG_IEEE]: "0x3c4d",
|
||||
[KITCHEN_IEEE]: "0x9c0d",
|
||||
[LANDING_IEEE]: "0xab12",
|
||||
[GARAGE_IEEE]: "0xcd34",
|
||||
[OFFICE_IEEE]: "0x7a8b",
|
||||
},
|
||||
stack_specific: {},
|
||||
metadata: { ezsp: { stack_version: "7.4.4.0" } },
|
||||
source: "[email protected]",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface ClusterDefinition {
|
||||
name: string;
|
||||
attributes: Attribute[];
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
const numberField = (name: string, max: number) => ({
|
||||
name,
|
||||
required: true,
|
||||
selector: { number: { min: 0, max, mode: "box" as const } },
|
||||
});
|
||||
|
||||
const CLUSTERS: Record<number, ClusterDefinition> = {
|
||||
0: {
|
||||
name: "Basic",
|
||||
attributes: [
|
||||
{ name: "zcl_version", id: 0 },
|
||||
{ name: "app_version", id: 1 },
|
||||
{ name: "manufacturer", id: 4 },
|
||||
{ name: "model", id: 5 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
1: {
|
||||
name: "PowerConfiguration",
|
||||
attributes: [
|
||||
{ name: "battery_voltage", id: 32 },
|
||||
{ name: "battery_percentage_remaining", id: 33 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
3: {
|
||||
name: "Identify",
|
||||
attributes: [{ name: "identify_time", id: 0 }],
|
||||
commands: [
|
||||
{
|
||||
name: "identify",
|
||||
id: 0,
|
||||
type: "server",
|
||||
schema: [numberField("identify_time", 65535)],
|
||||
},
|
||||
],
|
||||
},
|
||||
4: {
|
||||
name: "Groups",
|
||||
attributes: [{ name: "name_support", id: 0 }],
|
||||
commands: [],
|
||||
},
|
||||
6: {
|
||||
name: "OnOff",
|
||||
attributes: [{ name: "on_off", id: 0 }],
|
||||
commands: [
|
||||
{ name: "off", id: 0, type: "server", schema: [] },
|
||||
{ name: "on", id: 1, type: "server", schema: [] },
|
||||
{ name: "toggle", id: 2, type: "server", schema: [] },
|
||||
],
|
||||
},
|
||||
8: {
|
||||
name: "LevelControl",
|
||||
attributes: [{ name: "current_level", id: 0 }],
|
||||
commands: [
|
||||
{
|
||||
name: "move_to_level",
|
||||
id: 0,
|
||||
type: "server",
|
||||
schema: [
|
||||
numberField("level", 254),
|
||||
numberField("transition_time", 65535),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
25: {
|
||||
name: "Ota",
|
||||
attributes: [{ name: "current_file_version", id: 2 }],
|
||||
commands: [],
|
||||
},
|
||||
768: {
|
||||
name: "ColorControl",
|
||||
attributes: [
|
||||
{ name: "current_hue", id: 0 },
|
||||
{ name: "current_saturation", id: 1 },
|
||||
{ name: "color_temperature", id: 7 },
|
||||
],
|
||||
commands: [
|
||||
{
|
||||
name: "move_to_color_temp",
|
||||
id: 10,
|
||||
type: "server",
|
||||
schema: [
|
||||
numberField("color_temp_mireds", 500),
|
||||
numberField("transition_time", 65535),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
1026: {
|
||||
name: "TemperatureMeasurement",
|
||||
attributes: [
|
||||
{ name: "measured_value", id: 0 },
|
||||
{ name: "min_measured_value", id: 1 },
|
||||
{ name: "max_measured_value", id: 2 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
1280: {
|
||||
name: "IasZone",
|
||||
attributes: [
|
||||
{ name: "zone_state", id: 0 },
|
||||
{ name: "zone_type", id: 1 },
|
||||
{ name: "zone_status", id: 2 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
2820: {
|
||||
name: "ElectricalMeasurement",
|
||||
attributes: [
|
||||
{ name: "rms_voltage", id: 1285 },
|
||||
{ name: "rms_current", id: 1288 },
|
||||
{ name: "active_power", id: 1291 },
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
};
|
||||
|
||||
const clusterList = (inIds: number[], outIds: number[] = []): Cluster[] =>
|
||||
[
|
||||
...inIds.map((id) => ({ id, type: "in" })),
|
||||
...outIds.map((id) => ({ id, type: "out" })),
|
||||
].map(({ id, type }) => ({
|
||||
name: CLUSTERS[id].name,
|
||||
id,
|
||||
endpoint_id: 1,
|
||||
type,
|
||||
}));
|
||||
|
||||
// A device binds from its client side, and group binding lists only those, so
|
||||
// the remotes carry the `out` clusters they would have on real hardware and
|
||||
// the mains-powered devices only their OTA one.
|
||||
const DEVICE_CLUSTERS: Record<string, Cluster[]> = {
|
||||
[COORDINATOR_IEEE]: clusterList([0]),
|
||||
[PORCH_IEEE]: clusterList([0, 3, 4, 6, 8, 768], [25]),
|
||||
[MOTION_IEEE]: clusterList([0, 1, 3, 1280], [3, 6, 8]),
|
||||
[PLUG_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
|
||||
[KITCHEN_IEEE]: clusterList([0, 1, 3], [3, 6, 8]),
|
||||
[LANDING_IEEE]: clusterList([0, 1, 3, 1026]),
|
||||
[GARAGE_IEEE]: clusterList([0, 1, 3, 1280]),
|
||||
[OFFICE_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
|
||||
};
|
||||
|
||||
const DEFAULT_ATTRIBUTE_VALUES: Record<string, string> = {
|
||||
"1:32": "30",
|
||||
"1:33": "184",
|
||||
"3:0": "0",
|
||||
"4:0": "0",
|
||||
"6:0": "1",
|
||||
"8:0": "254",
|
||||
"768:0": "42",
|
||||
"768:1": "180",
|
||||
"768:7": "370",
|
||||
"1026:0": "2140",
|
||||
"1026:1": "-2000",
|
||||
"1026:2": "6000",
|
||||
"1280:0": "1",
|
||||
"1280:1": "21",
|
||||
"1280:2": "0",
|
||||
"2820:1285": "2300",
|
||||
"2820:1288": "410",
|
||||
"2820:1291": "94",
|
||||
};
|
||||
|
||||
const writtenAttributes = new Map<string, string>();
|
||||
|
||||
const attributeKey = (data: ReadAttributeServiceData) =>
|
||||
`${data.ieee}:${data.endpoint_id}:${data.cluster_id}:${data.attribute}`;
|
||||
|
||||
const attributeValue = (data: ReadAttributeServiceData): string => {
|
||||
const written = writtenAttributes.get(attributeKey(data));
|
||||
if (written !== undefined) {
|
||||
return written;
|
||||
}
|
||||
if (data.cluster_id === 0) {
|
||||
const device = DEVICES.find((candidate) => candidate.ieee === data.ieee);
|
||||
if (data.attribute === 4) {
|
||||
return device?.manufacturer ?? "";
|
||||
}
|
||||
if (data.attribute === 5) {
|
||||
return device?.model ?? "";
|
||||
}
|
||||
return "3";
|
||||
}
|
||||
return (
|
||||
DEFAULT_ATTRIBUTE_VALUES[`${data.cluster_id}:${data.attribute}`] ?? "0"
|
||||
);
|
||||
};
|
||||
|
||||
export const mockZha = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zha/devices", () => DEVICES);
|
||||
hass.mockWS("zha/device", (msg: { ieee: string }) =>
|
||||
DEVICES.find((device) => device.ieee === msg.ieee)
|
||||
);
|
||||
hass.mockWS("zha/groups", () => GROUPS);
|
||||
hass.mockWS("zha/group", (msg: { group_id: number }) =>
|
||||
GROUPS.find((group) => group.group_id === msg.group_id)
|
||||
);
|
||||
// Copied: both options editors mutate the fetched data as the user changes a
|
||||
// control, so handing out the backing object would persist edits that were
|
||||
// never saved. Only the update below writes to it.
|
||||
hass.mockWS("zha/configuration", () => structuredClone(CONFIGURATION));
|
||||
hass.mockWS("zha/network/settings", () => NETWORK_SETTINGS);
|
||||
hass.mockWS("zha/topology/update", () => undefined);
|
||||
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
|
||||
DEVICES.filter(
|
||||
(candidate) =>
|
||||
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
|
||||
)
|
||||
);
|
||||
hass.mockWS("zha/devices/bind", () => undefined);
|
||||
hass.mockWS("zha/devices/unbind", () => undefined);
|
||||
hass.mockWS("zha/groups/bind", () => undefined);
|
||||
hass.mockWS("zha/groups/unbind", () => undefined);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters",
|
||||
(msg: { ieee: string }) => DEVICE_CLUSTERS[msg.ieee] ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/attributes",
|
||||
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.attributes ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/commands",
|
||||
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.commands ?? []
|
||||
);
|
||||
hass.mockWS(
|
||||
"zha/devices/clusters/attributes/value",
|
||||
(msg: ReadAttributeServiceData) => attributeValue(msg)
|
||||
);
|
||||
hass.mockService("zha", "set_zigbee_cluster_attribute", (data) => {
|
||||
const write = data as ReadAttributeServiceData & { value: unknown };
|
||||
writtenAttributes.set(attributeKey(write), String(write.value));
|
||||
return undefined;
|
||||
});
|
||||
hass.mockWS("zha/devices/groupable", () => [
|
||||
member(PORCH_IEEE),
|
||||
member(OFFICE_IEEE),
|
||||
member(PLUG_IEEE),
|
||||
]);
|
||||
hass.mockWS("zha/network/backups/list", () => BACKUPS);
|
||||
|
||||
hass.mockWS("zha/devices/permit", () => () => undefined);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/devices/reconfigure",
|
||||
(msg: { ieee: string }, _hass, onChange) => {
|
||||
const deviceClusters = DEVICE_CLUSTERS[msg.ieee] ?? [];
|
||||
const timers: number[] = [];
|
||||
let cancelled = false;
|
||||
const emit = (event: ClusterConfigurationEvent, step: number) => {
|
||||
timers.push(
|
||||
window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
onChange!(event);
|
||||
}
|
||||
}, step * 400)
|
||||
);
|
||||
};
|
||||
|
||||
deviceClusters.forEach((cluster, index) => {
|
||||
emit(
|
||||
{
|
||||
type: "zha_channel_bind",
|
||||
zha_channel_msg_data: {
|
||||
cluster_name: cluster.name,
|
||||
cluster_id: cluster.id,
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
index + 1
|
||||
);
|
||||
const attributes: AttributeConfigurationStatus[] = CLUSTERS[
|
||||
cluster.id
|
||||
].attributes.map((attribute) => ({
|
||||
...attribute,
|
||||
status: "SUCCESS",
|
||||
min: 30,
|
||||
max: 900,
|
||||
change: 1,
|
||||
}));
|
||||
if (attributes.length) {
|
||||
emit(
|
||||
{
|
||||
type: "zha_channel_configure_reporting",
|
||||
zha_channel_msg_data: {
|
||||
cluster_name: cluster.name,
|
||||
cluster_id: cluster.id,
|
||||
attributes,
|
||||
},
|
||||
},
|
||||
index + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
emit({ type: "zha_channel_cfg_done" }, deviceClusters.length + 1);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/configuration/update",
|
||||
(msg: { data: ZHAConfiguration["data"] }) => {
|
||||
Object.entries(msg.data ?? {}).forEach(([section, values]) => {
|
||||
CONFIGURATION.data[section] = {
|
||||
...CONFIGURATION.data[section],
|
||||
...values,
|
||||
};
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("zha/network/backups/create", () => {
|
||||
const backup: ZHANetworkBackup = {
|
||||
backup_time: new Date().toISOString(),
|
||||
// Copied, or changing the channel afterwards would rewrite the backup
|
||||
// too, which is the one thing a backup must not do.
|
||||
network_info: structuredClone(NETWORK_SETTINGS.settings.network_info),
|
||||
node_info: structuredClone(NETWORK_SETTINGS.settings.node_info),
|
||||
};
|
||||
BACKUPS.push(backup);
|
||||
return { backup, is_complete: true };
|
||||
});
|
||||
|
||||
hass.mockWS(
|
||||
"zha/network/change_channel",
|
||||
(msg: { new_channel: "auto" | number }) => {
|
||||
NETWORK_SETTINGS.settings.network_info.channel =
|
||||
msg.new_channel === "auto" ? 25 : msg.new_channel;
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/add",
|
||||
(msg: {
|
||||
group_name: string;
|
||||
group_id?: number;
|
||||
members?: ZHAGroupMember[];
|
||||
}) => {
|
||||
const group: ZHAGroup = {
|
||||
name: msg.group_name,
|
||||
group_id:
|
||||
msg.group_id ??
|
||||
GROUPS.reduce(
|
||||
(highest, item) => Math.max(highest, item.group_id),
|
||||
0
|
||||
) + 1,
|
||||
members: (msg.members ?? []).map((item) => member(item.ieee)),
|
||||
};
|
||||
GROUPS.push(group);
|
||||
return group;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("zha/group/remove", (msg: { group_ids: number[] }) => {
|
||||
msg.group_ids.forEach((groupId) => {
|
||||
const index = GROUPS.findIndex((group) => group.group_id === groupId);
|
||||
if (index !== -1) {
|
||||
GROUPS.splice(index, 1);
|
||||
}
|
||||
});
|
||||
return GROUPS;
|
||||
});
|
||||
|
||||
const findGroup = (groupId: number) => {
|
||||
const group = GROUPS.find((candidate) => candidate.group_id === groupId);
|
||||
if (!group) {
|
||||
throw new Error(`Group ${groupId} not found`);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/members/add",
|
||||
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
|
||||
const group = findGroup(msg.group_id);
|
||||
const known = new Set(group.members.map((item) => item.device.ieee));
|
||||
group.members = [
|
||||
...group.members,
|
||||
...msg.members
|
||||
.filter((item) => !known.has(item.ieee))
|
||||
.map((item) => member(item.ieee)),
|
||||
];
|
||||
return group;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"zha/group/members/remove",
|
||||
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
|
||||
const group = findGroup(msg.group_id);
|
||||
const dropped = new Set(msg.members.map((item) => item.ieee));
|
||||
group.members = group.members.filter(
|
||||
(item) => !dropped.has(item.device.ieee)
|
||||
);
|
||||
return group;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,201 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
const ENTRY_ID = "mock-zwave-js";
|
||||
export const HOME_ID = 3245146787;
|
||||
|
||||
// Node IDs are carried in the `zwave_js` device identifiers, which is where the
|
||||
// panels read them back from.
|
||||
export const CONTROLLER_NODE_ID = 1;
|
||||
export const HALLWAY_NODE_ID = 7;
|
||||
export const DIMMER_NODE_ID = 12;
|
||||
export const MOTION_NODE_ID = 15;
|
||||
export const LOCK_NODE_ID = 18;
|
||||
export const THERMOSTAT_NODE_ID = 20;
|
||||
export const SENSOR_NODE_ID = 23;
|
||||
export const OUTLET_NODE_ID = 31;
|
||||
|
||||
export const DEVICE_IDS_BY_NODE_ID: Record<number, string> = {
|
||||
[CONTROLLER_NODE_ID]: "zwave-controller",
|
||||
[HALLWAY_NODE_ID]: "zwave-hallway-switch",
|
||||
[DIMMER_NODE_ID]: "zwave-dining-dimmer",
|
||||
[MOTION_NODE_ID]: "zwave-garage-motion",
|
||||
[LOCK_NODE_ID]: "zwave-back-door-lock",
|
||||
[THERMOSTAT_NODE_ID]: "zwave-bedroom-thermostat",
|
||||
[SENSOR_NODE_ID]: "zwave-basement-sensor",
|
||||
[OUTLET_NODE_ID]: "zwave-porch-outlet",
|
||||
};
|
||||
|
||||
const identifiers = (nodeId: number): [string, string][] => [
|
||||
["zwave_js", `${HOME_ID}-${nodeId}`],
|
||||
];
|
||||
|
||||
const DEVICES = [
|
||||
device(
|
||||
"zwave-controller",
|
||||
"Z-Wave stick",
|
||||
"Zooz",
|
||||
"800 Series Z-Wave Long Range",
|
||||
ENTRY_ID,
|
||||
{ sw_version: "1.10", identifiers: identifiers(CONTROLLER_NODE_ID) }
|
||||
),
|
||||
device(
|
||||
"zwave-dining-dimmer",
|
||||
"Dining room dimmer",
|
||||
"Inovelli",
|
||||
"LZW31-SN",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(DIMMER_NODE_ID) }
|
||||
),
|
||||
device("zwave-back-door-lock", "Back door lock", "Yale", "YRD226", ENTRY_ID, {
|
||||
identifiers: identifiers(LOCK_NODE_ID),
|
||||
}),
|
||||
device(
|
||||
"zwave-basement-sensor",
|
||||
"Basement sensor",
|
||||
"Aeotec",
|
||||
"ZWA005 TriSensor",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(SENSOR_NODE_ID) }
|
||||
),
|
||||
device("zwave-hallway-switch", "Hallway switch", "Zooz", "ZEN76", ENTRY_ID, {
|
||||
area_id: "entrance",
|
||||
identifiers: identifiers(HALLWAY_NODE_ID),
|
||||
}),
|
||||
device(
|
||||
"zwave-garage-motion",
|
||||
"Garage motion",
|
||||
"Aeotec",
|
||||
"MultiSensor 7",
|
||||
ENTRY_ID,
|
||||
{ identifiers: identifiers(MOTION_NODE_ID) }
|
||||
),
|
||||
device(
|
||||
"zwave-bedroom-thermostat",
|
||||
"Bedroom thermostat",
|
||||
"Honeywell",
|
||||
"T6 Pro",
|
||||
ENTRY_ID,
|
||||
{ area_id: "bedroom", identifiers: identifiers(THERMOSTAT_NODE_ID) }
|
||||
),
|
||||
device("zwave-porch-outlet", "Porch outlet", "Zooz", "ZEN15", ENTRY_ID, {
|
||||
identifiers: identifiers(OUTLET_NODE_ID),
|
||||
}),
|
||||
];
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"light.dining_room",
|
||||
"zwave-dining-dimmer",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry("lock.back_door", "zwave-back-door-lock", ENTRY_ID, "zwave_js"),
|
||||
registryEntry(
|
||||
"sensor.basement_humidity",
|
||||
"zwave-basement-sensor",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry("light.hallway", "zwave-hallway-switch", ENTRY_ID, "zwave_js"),
|
||||
registryEntry(
|
||||
"binary_sensor.garage_motion",
|
||||
"zwave-garage-motion",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry(
|
||||
"climate.bedroom",
|
||||
"zwave-bedroom-thermostat",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
registryEntry(
|
||||
"switch.porch_outlet",
|
||||
"zwave-porch-outlet",
|
||||
ENTRY_ID,
|
||||
"zwave_js"
|
||||
),
|
||||
];
|
||||
|
||||
export const zwaveJsFixtures: ConnectivityFixtures = {
|
||||
components: ["zwave_js"],
|
||||
commands: ["zwave_js/"],
|
||||
manifests: [manifest("zwave_js", "Z-Wave", { integration_type: "hub" })],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(ENTRY_ID, "zwave_js", "Z-Wave", {
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
devices: DEVICES,
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"light.dining_room": {
|
||||
entity_id: "light.dining_room",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Dining room dimmer",
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 128,
|
||||
},
|
||||
},
|
||||
"lock.back_door": {
|
||||
entity_id: "lock.back_door",
|
||||
state: "unlocked",
|
||||
attributes: { friendly_name: "Back door lock" },
|
||||
},
|
||||
"sensor.basement_humidity": {
|
||||
entity_id: "sensor.basement_humidity",
|
||||
state: "58",
|
||||
attributes: {
|
||||
friendly_name: "Basement humidity",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"light.hallway": {
|
||||
entity_id: "light.hallway",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Hallway switch",
|
||||
supported_color_modes: ["onoff"],
|
||||
},
|
||||
},
|
||||
"binary_sensor.garage_motion": {
|
||||
entity_id: "binary_sensor.garage_motion",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Garage motion", device_class: "motion" },
|
||||
},
|
||||
"climate.bedroom": {
|
||||
entity_id: "climate.bedroom",
|
||||
state: "heat",
|
||||
attributes: {
|
||||
friendly_name: "Bedroom thermostat",
|
||||
hvac_modes: ["off", "heat"],
|
||||
current_temperature: 19.6,
|
||||
temperature: 20.5,
|
||||
min_temp: 7,
|
||||
max_temp: 30,
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
"switch.porch_outlet": {
|
||||
entity_id: "switch.porch_outlet",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Porch outlet" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,229 +0,0 @@
|
||||
import type {
|
||||
ZWaveJSController,
|
||||
ZWaveJSNetwork,
|
||||
ZWaveJSNodeStatisticsUpdatedMessage,
|
||||
ZWaveJSNodeStatus,
|
||||
ZwaveJSProvisioningEntry,
|
||||
} from "../../../../../src/data/zwave_js";
|
||||
import {
|
||||
NodeStatus,
|
||||
ProvisioningEntryStatus,
|
||||
SecurityClass,
|
||||
} from "../../../../../src/data/zwave_js";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { emitInitial } from "../subscription";
|
||||
|
||||
import {
|
||||
CONTROLLER_NODE_ID,
|
||||
DEVICE_IDS_BY_NODE_ID,
|
||||
DIMMER_NODE_ID,
|
||||
HALLWAY_NODE_ID,
|
||||
HOME_ID,
|
||||
LOCK_NODE_ID,
|
||||
MOTION_NODE_ID,
|
||||
OUTLET_NODE_ID,
|
||||
SENSOR_NODE_ID,
|
||||
THERMOSTAT_NODE_ID,
|
||||
} from "./fixtures";
|
||||
|
||||
const node = (
|
||||
nodeId: number,
|
||||
status: NodeStatus,
|
||||
overrides: Partial<ZWaveJSNodeStatus> = {}
|
||||
): ZWaveJSNodeStatus => ({
|
||||
node_id: nodeId,
|
||||
ready: true,
|
||||
status,
|
||||
is_secure: true,
|
||||
is_routing: true,
|
||||
zwave_plus_version: 2,
|
||||
highest_security_class: SecurityClass.S2_Authenticated,
|
||||
is_controller_node: false,
|
||||
has_firmware_update_cc: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const NODES: ZWaveJSNodeStatus[] = [
|
||||
node(CONTROLLER_NODE_ID, NodeStatus.Alive, {
|
||||
is_controller_node: true,
|
||||
highest_security_class: SecurityClass.S2_AccessControl,
|
||||
}),
|
||||
node(HALLWAY_NODE_ID, NodeStatus.Alive),
|
||||
node(DIMMER_NODE_ID, NodeStatus.Alive),
|
||||
node(MOTION_NODE_ID, NodeStatus.Asleep, { is_routing: false }),
|
||||
node(LOCK_NODE_ID, NodeStatus.Asleep, {
|
||||
is_routing: false,
|
||||
highest_security_class: SecurityClass.S2_AccessControl,
|
||||
}),
|
||||
node(THERMOSTAT_NODE_ID, NodeStatus.Alive),
|
||||
node(SENSOR_NODE_ID, NodeStatus.Dead, { is_routing: false }),
|
||||
node(OUTLET_NODE_ID, NodeStatus.Alive),
|
||||
];
|
||||
|
||||
const CONTROLLER: ZWaveJSController = {
|
||||
home_id: HOME_ID,
|
||||
sdk_version: "7.19.3",
|
||||
type: 1,
|
||||
own_node_id: CONTROLLER_NODE_ID,
|
||||
rf_region: null,
|
||||
is_primary: true,
|
||||
is_using_home_id_from_other_network: false,
|
||||
is_sis_present: true,
|
||||
was_real_primary: true,
|
||||
is_suc: true,
|
||||
// NodeType.Controller; the enum itself is not exported from data/zwave_js.
|
||||
node_type: 0 as ZWaveJSController["node_type"],
|
||||
firmware_version: "1.10",
|
||||
manufacturer_id: 634,
|
||||
product_id: 4,
|
||||
product_type: 3,
|
||||
supported_function_types: [],
|
||||
suc_node_id: CONTROLLER_NODE_ID,
|
||||
supports_timers: false,
|
||||
is_rebuilding_routes: false,
|
||||
// InclusionState.Idle
|
||||
inclusion_state: 0,
|
||||
nodes: NODES,
|
||||
supports_long_range: true,
|
||||
};
|
||||
|
||||
const NETWORK: ZWaveJSNetwork = {
|
||||
client: {
|
||||
state: "connected",
|
||||
ws_server_url: "ws://localhost:3000",
|
||||
server_version: "1.40.1",
|
||||
driver_version: "13.2.0",
|
||||
},
|
||||
controller: CONTROLLER,
|
||||
};
|
||||
|
||||
const PROVISIONING_ENTRIES: ZwaveJSProvisioningEntry[] = [
|
||||
{
|
||||
dsk: "51590-27189-49239-34778-15304-59293-52843-45852",
|
||||
securityClasses: [SecurityClass.S2_Authenticated],
|
||||
status: ProvisioningEntryStatus.Active,
|
||||
additionalProperties: {},
|
||||
manufacturer: "Zooz",
|
||||
label: "ZEN32 Scene Controller",
|
||||
},
|
||||
];
|
||||
|
||||
// Node IDs each node can reach directly. Only requested when the map's
|
||||
// neighbor overlay is toggled on.
|
||||
const NEIGHBORS: Record<number, number[]> = {
|
||||
[CONTROLLER_NODE_ID]: [HALLWAY_NODE_ID, DIMMER_NODE_ID, OUTLET_NODE_ID],
|
||||
[HALLWAY_NODE_ID]: [
|
||||
CONTROLLER_NODE_ID,
|
||||
DIMMER_NODE_ID,
|
||||
LOCK_NODE_ID,
|
||||
THERMOSTAT_NODE_ID,
|
||||
],
|
||||
[DIMMER_NODE_ID]: [
|
||||
CONTROLLER_NODE_ID,
|
||||
HALLWAY_NODE_ID,
|
||||
SENSOR_NODE_ID,
|
||||
OUTLET_NODE_ID,
|
||||
],
|
||||
[MOTION_NODE_ID]: [THERMOSTAT_NODE_ID],
|
||||
[LOCK_NODE_ID]: [HALLWAY_NODE_ID],
|
||||
[THERMOSTAT_NODE_ID]: [HALLWAY_NODE_ID, MOTION_NODE_ID],
|
||||
[SENSOR_NODE_ID]: [DIMMER_NODE_ID],
|
||||
[OUTLET_NODE_ID]: [CONTROLLER_NODE_ID, DIMMER_NODE_ID],
|
||||
};
|
||||
|
||||
// Route each node reports as its last working route back to the controller,
|
||||
// so the map can draw the mesh instead of a star.
|
||||
const ROUTES: Record<number, { repeaters: number[]; rssi: number }> = {
|
||||
[HALLWAY_NODE_ID]: { repeaters: [], rssi: -44 },
|
||||
[DIMMER_NODE_ID]: { repeaters: [], rssi: -48 },
|
||||
[OUTLET_NODE_ID]: { repeaters: [], rssi: -57 },
|
||||
[LOCK_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -72 },
|
||||
[THERMOSTAT_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -66 },
|
||||
[MOTION_NODE_ID]: {
|
||||
repeaters: [HALLWAY_NODE_ID, THERMOSTAT_NODE_ID],
|
||||
rssi: -79,
|
||||
},
|
||||
[SENSOR_NODE_ID]: { repeaters: [DIMMER_NODE_ID], rssi: -81 },
|
||||
};
|
||||
|
||||
const NODE_IDS_BY_DEVICE_ID: Record<string, number> = Object.fromEntries(
|
||||
Object.entries(DEVICE_IDS_BY_NODE_ID).map(([nodeId, deviceId]) => [
|
||||
deviceId,
|
||||
Number(nodeId),
|
||||
])
|
||||
);
|
||||
|
||||
const buildNodeStatistics = (
|
||||
nodeId: number
|
||||
): ZWaveJSNodeStatisticsUpdatedMessage => {
|
||||
const route = ROUTES[nodeId];
|
||||
return {
|
||||
event: "statistics updated",
|
||||
source: "node",
|
||||
nodeId,
|
||||
node_id: nodeId,
|
||||
commands_tx: 1200 + nodeId * 7,
|
||||
commands_rx: 980 + nodeId * 5,
|
||||
commands_dropped_tx: 0,
|
||||
commands_dropped_rx: nodeId === SENSOR_NODE_ID ? 4 : 0,
|
||||
timeout_response: 0,
|
||||
rtt: 24 + nodeId,
|
||||
rssi: route?.rssi ?? null,
|
||||
lwr: route
|
||||
? {
|
||||
protocol_data_rate: 3,
|
||||
repeaters: route.repeaters.map(
|
||||
(repeaterNodeId) => DEVICE_IDS_BY_NODE_ID[repeaterNodeId]
|
||||
),
|
||||
rssi: route.rssi,
|
||||
repeater_rssi: route.repeaters.map(() => -55),
|
||||
route_failed_between: null,
|
||||
}
|
||||
: null,
|
||||
nlwr: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockZwaveJs = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zwave_js/network_status", () => NETWORK);
|
||||
hass.mockWS("zwave_js/network_neighbors", () => NEIGHBORS);
|
||||
hass.mockWS("zwave_js/get_provisioning_entries", () => PROVISIONING_ENTRIES);
|
||||
hass.mockWS("zwave_js/data_collection_status", () => ({
|
||||
enabled: false,
|
||||
opted_in: false,
|
||||
}));
|
||||
hass.mockWS("zwave_js/subscribe_s2_inclusion", () => () => undefined);
|
||||
hass.mockWS("zwave_js/node_status", (msg: { device_id: string }) => {
|
||||
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
|
||||
return NODES.find((n) => n.node_id === nodeId) ?? NODES[0];
|
||||
});
|
||||
hass.mockWS(
|
||||
"zwave_js/subscribe_node_statistics",
|
||||
(msg: { device_id: string }, _hass, onChange) => {
|
||||
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
|
||||
if (nodeId === undefined || nodeId === CONTROLLER_NODE_ID) {
|
||||
return () => undefined;
|
||||
}
|
||||
return emitInitial(() => onChange?.(buildNodeStatistics(nodeId)));
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"zwave_js/subscribe_controller_statistics",
|
||||
(_msg, _hass, onChange) =>
|
||||
emitInitial(() =>
|
||||
onChange?.({
|
||||
event: "statistics updated",
|
||||
source: "controller",
|
||||
messages_tx: 18234,
|
||||
messages_rx: 17980,
|
||||
messages_dropped_tx: 2,
|
||||
messages_dropped_rx: 5,
|
||||
nak: 0,
|
||||
can: 3,
|
||||
timeout_ack: 1,
|
||||
timeout_response: 0,
|
||||
timeout_callback: 0,
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import { connectivityDevices } from "./connectivity/fixtures";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
@@ -15,7 +14,6 @@ const baseDevice = {
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
@@ -52,40 +50,4 @@ 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,11 +102,6 @@ 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 => ({
|
||||
|
||||
@@ -358,12 +358,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/ingress/panels") {
|
||||
// The navigation picker waits for this collection before it enables
|
||||
// itself, so it has to answer even when no add-on exposes a panel.
|
||||
return { panels: {} };
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/store/reload") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityManifests } from "./connectivity/fixtures";
|
||||
import { manifest } from "./manifest";
|
||||
|
||||
const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const manifests: IntegrationManifest[] = [
|
||||
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
|
||||
@@ -50,18 +62,11 @@ const manifests: IntegrationManifest[] = [
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
...connectivityManifests,
|
||||
];
|
||||
|
||||
export const mockIntegration = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("manifest/list", () => manifests);
|
||||
// Never answer with undefined: the integration page reads the manifest it
|
||||
// gets back without guarding, so an unlisted domain would throw. The real
|
||||
// backend always has a manifest for a domain that has config entries.
|
||||
hass.mockWS(
|
||||
"manifest/get",
|
||||
(msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration) ??
|
||||
manifest(msg.integration, msg.integration)
|
||||
hass.mockWS("manifest/get", (msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
|
||||
/**
|
||||
* Builds a demo integration manifest. Lives in its own module so both the
|
||||
* manifest registry and the per-integration fixtures that feed it can use it
|
||||
* without importing each other.
|
||||
*/
|
||||
export const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
@@ -1,12 +1,6 @@
|
||||
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",
|
||||
|
||||
+2
-54
@@ -1,58 +1,6 @@
|
||||
import type { Tag, UpdateTagParams } from "../../../src/data/tag";
|
||||
import type { Tag } from "../../../src/data/tag";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockTags = (hass: MockHomeAssistant) => {
|
||||
const tags: Tag[] = [{ id: "my-tag", name: "My Tag" }];
|
||||
let created = 0;
|
||||
|
||||
const find = (tagId: string) => tags.find((tag) => tag.id === tagId);
|
||||
|
||||
hass.mockWS("tag/list", () => tags.map((tag) => ({ ...tag })));
|
||||
|
||||
hass.mockWS(
|
||||
"tag/create",
|
||||
(msg: UpdateTagParams & { tag_id?: string }): Tag => {
|
||||
if (msg.tag_id && find(msg.tag_id)) {
|
||||
throw new Error(`Tag ${msg.tag_id} already exists`);
|
||||
}
|
||||
let id = msg.tag_id;
|
||||
while (!id) {
|
||||
created += 1;
|
||||
id = find(`tag-${created}`) ? undefined : `tag-${created}`;
|
||||
}
|
||||
const tag: Tag = {
|
||||
id,
|
||||
name: msg.name,
|
||||
description: msg.description,
|
||||
};
|
||||
tags.push(tag);
|
||||
return { ...tag };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"tag/update",
|
||||
(msg: UpdateTagParams & { tag_id: string }): Tag => {
|
||||
const tag = find(msg.tag_id);
|
||||
if (!tag) {
|
||||
throw new Error(`Tag ${msg.tag_id} not found`);
|
||||
}
|
||||
if ("name" in msg) {
|
||||
tag.name = msg.name;
|
||||
}
|
||||
if ("description" in msg) {
|
||||
tag.description = msg.description;
|
||||
}
|
||||
return { ...tag };
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("tag/delete", (msg: { tag_id: string }) => {
|
||||
const index = tags.findIndex((tag) => tag.id === msg.tag_id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Tag ${msg.tag_id} not found`);
|
||||
}
|
||||
tags.splice(index, 1);
|
||||
return undefined;
|
||||
});
|
||||
hass.mockWS("tag/list", () => [{ id: "my-tag", name: "My Tag" }] as Tag[]);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { connectivityBackendTranslations } from "./connectivity/fixtures";
|
||||
|
||||
export const mockTranslations = (
|
||||
hass: MockHomeAssistant,
|
||||
localizePromise?: Promise<LocalizeFunc>
|
||||
) => {
|
||||
hass.mockWS(
|
||||
"frontend/get_translations",
|
||||
(msg: { language: string; category?: string }) => ({
|
||||
resources:
|
||||
(msg.category && connectivityBackendTranslations[msg.category]) || {},
|
||||
})
|
||||
);
|
||||
|
||||
// `hass.loadBackendTranslation` is a no-op in the mocked hass, so categories
|
||||
// that are only requested through it never reach the WebSocket mock above.
|
||||
// Seed every category into the resources, after the fragment translations so
|
||||
// this merges on top of them.
|
||||
(localizePromise ?? Promise.resolve()).then(() =>
|
||||
hass.addTranslations(
|
||||
Object.assign({}, ...Object.values(connectivityBackendTranslations))
|
||||
)
|
||||
);
|
||||
export const mockTranslations = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("frontend/get_translations", (
|
||||
/* msg: {language: string, category: string} */
|
||||
) => ({ resources: {} }));
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
|
||||
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import "../../src/components/ha-button";
|
||||
import "../../src/components/ha-drawer";
|
||||
@@ -683,7 +682,10 @@ class HaGallery extends LitElement {
|
||||
formatEntityAttributeName: (_stateObj, attribute) => attribute,
|
||||
formatEntityAttributeValue: (stateObj, attribute, value) =>
|
||||
value != null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||
formatEntityName: computeEntityNameDisplayWithoutContext,
|
||||
formatEntityName: (stateObj, type) =>
|
||||
typeof type === "string"
|
||||
? type
|
||||
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||
} as unknown as HomeAssistant;
|
||||
}
|
||||
|
||||
@@ -691,7 +693,9 @@ class HaGallery extends LitElement {
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
user-select: initial;
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
--ha-sidebar-width: 300px;
|
||||
--ha-sidebar-expanded-width: 300px;
|
||||
--ha-sidebar-expanded-item-width: 292px;
|
||||
|
||||
@@ -25,9 +25,6 @@ title: Button
|
||||
<ha-button appearance="filled">
|
||||
filled button
|
||||
</ha-button>
|
||||
<ha-button appearance="outlined">
|
||||
outlined button
|
||||
</ha-button>
|
||||
|
||||
<ha-button size="s">
|
||||
small
|
||||
@@ -68,7 +65,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| appearance | "accent"/"filled"/"outlined"/"plain" | "accent" | Sets the button appearance. |
|
||||
| appearance | "accent"/"filled"/"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", "outlined", "plain"];
|
||||
const appearances = ["accent", "filled", "plain"];
|
||||
const variants = ["brand", "danger", "neutral", "warning", "success"];
|
||||
|
||||
@customElement("demo-components-ha-button")
|
||||
|
||||
@@ -87,7 +87,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
@@ -112,7 +111,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
@@ -137,7 +135,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ This component is based on the webawesome input component.
|
||||
| invalid | Boolean | false | Marks the input as invalid. |
|
||||
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
|
||||
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
|
||||
| input-id | String | - | Sets the native input's `id` and associated label `for` on the inner `wa-input`. Defaults to `"input"`. |
|
||||
| pattern | String | - | A regular expression pattern to validate input against. |
|
||||
| minlength | Number | - | The minimum length of input that will be considered valid. |
|
||||
| maxlength | Number | - | The maximum length of input that will be considered valid. |
|
||||
|
||||
@@ -29,50 +29,6 @@ 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;
|
||||
@@ -85,8 +41,6 @@ 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 {
|
||||
@@ -320,45 +274,6 @@ 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) =>
|
||||
@@ -446,43 +361,6 @@ 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;
|
||||
};
|
||||
@@ -565,9 +443,6 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.child::part(base) {
|
||||
padding-inline-start: var(--ha-space-12);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -8,6 +9,7 @@ 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";
|
||||
@@ -48,7 +50,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
@@ -73,7 +74,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -153,6 +153,9 @@ 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[] = [
|
||||
@@ -163,6 +166,14 @@ 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:
|
||||
@@ -261,13 +272,23 @@ class DemoHaSelectorReplacedDevice
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${sample.name}</span>
|
||||
<span slot="description">${sample.description}</span>
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>
|
||||
${
|
||||
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-settings-row>
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -21,8 +21,6 @@ import "../../../../src/components/ha-settings-row";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import type { SerialPort } from "../../../../src/data/usb";
|
||||
import {
|
||||
showDialog,
|
||||
type ShowDialogParams,
|
||||
@@ -102,7 +100,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
@@ -127,7 +124,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
@@ -152,85 +148,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -295,72 +212,9 @@ const LABELS: LabelRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const serialPort = (port: Partial<SerialPort>): SerialPort => ({
|
||||
device: "/dev/ttyUSB0",
|
||||
resolved_device: null,
|
||||
serial_number: null,
|
||||
manufacturer: null,
|
||||
description: null,
|
||||
matching_integrations: [],
|
||||
present: true,
|
||||
...port,
|
||||
});
|
||||
|
||||
// One port per section the picker groups by, relative to the "zha" domain the
|
||||
// serial port selector below is given as its flow context
|
||||
const SERIAL_PORTS: SerialPort[] = [
|
||||
serialPort({
|
||||
device:
|
||||
"/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
|
||||
description: "SkyConnect v1.0",
|
||||
manufacturer: "Nabu Casa",
|
||||
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
matching_integrations: ["zha"],
|
||||
}),
|
||||
serialPort({
|
||||
device: "esphome-hass://01JQ8Z5X9WQ0/?port_name=UART0",
|
||||
}),
|
||||
serialPort({
|
||||
device: "socket://192.168.1.10:6638",
|
||||
}),
|
||||
serialPort({
|
||||
device: "/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_AB0KVD1L-if00-port0",
|
||||
description: "FT232R USB UART",
|
||||
manufacturer: "FTDI",
|
||||
serial_number: "AB0KVD1L",
|
||||
vid: "0403",
|
||||
pid: "6001",
|
||||
}),
|
||||
serialPort({
|
||||
device: "/dev/ttyS0",
|
||||
description: "ttyS0",
|
||||
manufacturer: "Intel",
|
||||
}),
|
||||
serialPort({ device: "/dev/ttyAMA0" }),
|
||||
serialPort({
|
||||
device:
|
||||
"/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0",
|
||||
description: "CP2102 USB to UART Bridge Controller",
|
||||
manufacturer: "Silicon Labs",
|
||||
serial_number: "0001",
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
matching_integrations: ["matter"],
|
||||
}),
|
||||
];
|
||||
|
||||
const SCHEMAS: {
|
||||
name: string;
|
||||
input: Record<
|
||||
string,
|
||||
| (BlueprintInput & {
|
||||
required?: boolean;
|
||||
context?: Record<string, unknown>;
|
||||
})
|
||||
| null
|
||||
>;
|
||||
input: Record<string, (BlueprintInput & { required?: boolean }) | null>;
|
||||
}[] = [
|
||||
{
|
||||
name: "One of each",
|
||||
@@ -385,13 +239,6 @@ const SCHEMAS: {
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
serial_port: {
|
||||
name: "Serial port",
|
||||
selector: { serial_port: {} },
|
||||
// A config flow passes its own domain as context, which is what the
|
||||
// picker groups recommended and not recommended ports by
|
||||
context: { domain: "zha" },
|
||||
},
|
||||
number_box: {
|
||||
name: "Number Box",
|
||||
selector: {
|
||||
@@ -496,23 +343,6 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
device_class: {
|
||||
name: "Device Class",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
},
|
||||
},
|
||||
},
|
||||
device_class_multiple: {
|
||||
name: "Device Class (Multiple)",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "binary_sensor",
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select_custom: {
|
||||
name: "Select (Custom)",
|
||||
selector: {
|
||||
@@ -576,99 +406,6 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
addon: { name: "Add-on", selector: { addon: {} } },
|
||||
areas_display: {
|
||||
name: "Areas display",
|
||||
selector: { areas_display: {} },
|
||||
},
|
||||
assist_pipeline: {
|
||||
name: "Assist pipeline",
|
||||
selector: { assist_pipeline: {} },
|
||||
},
|
||||
automation_behavior: {
|
||||
name: "Automation behavior",
|
||||
selector: { automation_behavior: { mode: "trigger" } },
|
||||
},
|
||||
backup_location: {
|
||||
name: "Backup location",
|
||||
selector: { backup_location: {} },
|
||||
},
|
||||
button_toggle: {
|
||||
name: "Button toggle",
|
||||
selector: {
|
||||
button_toggle: {
|
||||
options: [
|
||||
{ label: "Left", value: "left" },
|
||||
{ label: "Center", value: "center" },
|
||||
{ label: "Right", value: "right" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
condition: { name: "Condition", selector: { condition: {} } },
|
||||
conversation_agent: {
|
||||
name: "Conversation agent",
|
||||
selector: { conversation_agent: {} },
|
||||
},
|
||||
country: {
|
||||
name: "Country",
|
||||
selector: { country: { countries: ["DE", "GB", "NL", "US"] } },
|
||||
},
|
||||
entity_name: {
|
||||
name: "Entity name",
|
||||
selector: { entity_name: { entity_id: "light.bedroom" } },
|
||||
},
|
||||
file: {
|
||||
name: "File",
|
||||
selector: { file: { accept: "image/png,image/jpeg" } },
|
||||
},
|
||||
language: { name: "Language", selector: { language: {} } },
|
||||
navigation: { name: "Navigation", selector: { navigation: {} } },
|
||||
numeric_threshold: {
|
||||
name: "Numeric threshold",
|
||||
selector: { numeric_threshold: {} },
|
||||
},
|
||||
period: {
|
||||
name: "Period",
|
||||
selector: {
|
||||
period: {
|
||||
options: ["today", "yesterday", "this_week", "this_month"],
|
||||
},
|
||||
},
|
||||
// Without a value that matches one of the options the selector offers
|
||||
// its custom-period editor instead of the list
|
||||
default: { calendar: { period: "day" } },
|
||||
},
|
||||
selector: {
|
||||
name: "Selector",
|
||||
selector: { selector: {} },
|
||||
default: { text: {} },
|
||||
},
|
||||
state_class: { name: "State class", selector: { state_class: {} } },
|
||||
statistic: { name: "Statistic", selector: { statistic: {} } },
|
||||
stt: { name: "Speech-to-text", selector: { stt: {} } },
|
||||
theme: { name: "Theme", selector: { theme: {} } },
|
||||
timezone: { name: "Time zone", selector: { timezone: {} } },
|
||||
trigger: { name: "Trigger", selector: { trigger: {} } },
|
||||
tts: { name: "Text-to-speech", selector: { tts: {} } },
|
||||
tts_voice: {
|
||||
name: "Text-to-speech voice",
|
||||
selector: { tts_voice: { engineId: "tts.cloud", language: "en-US" } },
|
||||
},
|
||||
ui_action: { name: "UI action", selector: { ui_action: {} } },
|
||||
ui_clock_date_format: {
|
||||
name: "Clock date format",
|
||||
selector: { ui_clock_date_format: {} },
|
||||
},
|
||||
ui_color: { name: "UI color", selector: { ui_color: {} } },
|
||||
ui_state_content: {
|
||||
name: "State content",
|
||||
selector: { ui_state_content: { entity_id: "light.bedroom" } },
|
||||
},
|
||||
ui_time_format: {
|
||||
name: "Time format",
|
||||
selector: { ui_time_format: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -770,98 +507,8 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
mockFloorRegistry(hass, FLOORS);
|
||||
mockLabelRegistry(hass, LABELS);
|
||||
mockHassioSupervisor(hass);
|
||||
hass.addTranslations({
|
||||
"component.matter.title": "Matter",
|
||||
"component.zha.title": "Zigbee Home Automation",
|
||||
});
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...hass.config.components, "usb"],
|
||||
},
|
||||
});
|
||||
hass.mockWS("auth/sign_path", (params) => params);
|
||||
hass.mockWS("media_player/browse_media", this._browseMedia);
|
||||
hass.mockWS("usb/list_serial_ports", () => SERIAL_PORTS);
|
||||
hass.mockWS("recorder/list_statistic_ids", () => [
|
||||
{
|
||||
statistic_id: "sensor.energy_consumption",
|
||||
statistics_unit_of_measurement: "kWh",
|
||||
source: "recorder",
|
||||
name: null,
|
||||
has_sum: true,
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
unit_class: "energy",
|
||||
},
|
||||
{
|
||||
statistic_id: "sensor.outside_temperature",
|
||||
statistics_unit_of_measurement: "\u00b0C",
|
||||
source: "recorder",
|
||||
name: null,
|
||||
has_sum: false,
|
||||
mean_type: StatisticMeanType.ARITHMETIC,
|
||||
unit_class: "temperature",
|
||||
},
|
||||
]);
|
||||
hass.mockWS("assist_pipeline/pipeline/list", () => ({
|
||||
pipelines: [
|
||||
{
|
||||
id: "pipeline_home",
|
||||
name: "Home Assistant",
|
||||
language: "en",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "en",
|
||||
stt_engine: "stt.cloud",
|
||||
stt_language: "en-US",
|
||||
tts_engine: "tts.cloud",
|
||||
tts_language: "en-US",
|
||||
tts_voice: "JennyNeural",
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
],
|
||||
preferred_pipeline: "pipeline_home",
|
||||
}));
|
||||
hass.mockWS("conversation/agent/list", () => ({
|
||||
agents: [
|
||||
{
|
||||
id: "conversation.home_assistant",
|
||||
name: "Home Assistant",
|
||||
supported_languages: "*",
|
||||
},
|
||||
{
|
||||
id: "conversation.openai",
|
||||
name: "OpenAI Conversation",
|
||||
supported_languages: ["en"],
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("stt/engine/list", () => ({
|
||||
providers: [
|
||||
{
|
||||
engine_id: "stt.cloud",
|
||||
name: "Home Assistant Cloud",
|
||||
supported_languages: ["en-US"],
|
||||
deprecated: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("tts/engine/list", () => ({
|
||||
providers: [
|
||||
{
|
||||
engine_id: "tts.cloud",
|
||||
name: "Home Assistant Cloud",
|
||||
supported_languages: ["en-US"],
|
||||
deprecated: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("tts/engine/voices", () => ({
|
||||
voices: [
|
||||
{ voice_id: "JennyNeural", name: "Jenny" },
|
||||
{ voice_id: "GuyNeural", name: "Guy" },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
public provideHass(el) {
|
||||
@@ -1031,7 +678,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${value!.selector}
|
||||
.context=${value!.context}
|
||||
.key=${key}
|
||||
.label=${this._label ? value!.name : undefined}
|
||||
.value=${data[key] ?? value!.default}
|
||||
|
||||
@@ -21,8 +21,7 @@ const ENTITIES = [
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Dining Room",
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
supported_features: 1,
|
||||
brightness: 100,
|
||||
},
|
||||
},
|
||||
@@ -31,7 +30,7 @@ const ENTITIES = [
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Dining Room",
|
||||
supported_color_modes: ["brightness"],
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -39,7 +38,7 @@ const ENTITIES = [
|
||||
state: "unavailable",
|
||||
attributes: {
|
||||
friendly_name: "Lost Light",
|
||||
supported_color_modes: ["brightness"],
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,10 +12,7 @@ import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { ClimateEntityFeature } from "../../../../src/data/climate";
|
||||
import { FanEntityFeature } from "../../../../src/data/fan";
|
||||
import type {
|
||||
GridCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
@@ -135,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:
|
||||
@@ -166,39 +163,6 @@ const ENTITIES = [
|
||||
FanEntityFeature.OSCILLATE,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.three_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Desk fan",
|
||||
device_class: "fan",
|
||||
percentage: 67,
|
||||
percentage_step: 100 / 3,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.four_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Bedroom fan",
|
||||
device_class: "fan",
|
||||
percentage: 50,
|
||||
percentage_step: 25,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.five_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Attic fan",
|
||||
device_class: "fan",
|
||||
percentage: 80,
|
||||
percentage_step: 20,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGS = [
|
||||
@@ -368,50 +332,6 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (3 speeds)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.three_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (4 speeds)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.four_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (5 speeds)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.five_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (half width)",
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
square: false,
|
||||
cards: [
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.four_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.five_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan oscillate feature",
|
||||
config: {
|
||||
@@ -420,85 +340,7 @@ 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 | GridCardConfig>[];
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
class DemoTile extends LitElement {
|
||||
|
||||
@@ -39,7 +39,6 @@ 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,
|
||||
@@ -239,7 +238,6 @@ const createDeviceRegistryEntries = (
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -21,61 +21,6 @@ const ENTITIES = [
|
||||
FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.one_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "One speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 100,
|
||||
percentage_step: 100,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.two_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Two speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 50,
|
||||
percentage_step: 50,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.three_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Three speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 67,
|
||||
percentage_step: 100 / 3,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.four_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Four speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 50,
|
||||
percentage_step: 25,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.five_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Five speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 80,
|
||||
percentage_step: 20,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-fan")
|
||||
|
||||
@@ -11,7 +11,6 @@ import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
|
||||
const ALL_FEATURES =
|
||||
LawnMowerEntityFeature.START_MOWING +
|
||||
LawnMowerEntityFeature.PAUSE +
|
||||
LawnMowerEntityFeature.STOP +
|
||||
LawnMowerEntityFeature.DOCK;
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -50,14 +49,6 @@ const ENTITIES = [
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.idle",
|
||||
state: "idle",
|
||||
attributes: {
|
||||
friendly_name: "Idle",
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.error",
|
||||
state: "error",
|
||||
|
||||
@@ -16,25 +16,6 @@ const ENTITIES = [
|
||||
duration: "0:05:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.active_timer",
|
||||
state: "active",
|
||||
attributes: {
|
||||
friendly_name: "Active timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:10:00",
|
||||
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.paused_timer",
|
||||
state: "paused",
|
||||
attributes: {
|
||||
friendly_name: "Paused timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:03:21",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-timer")
|
||||
|
||||
@@ -15,6 +15,7 @@ const ALL_FEATURES =
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.FAN_SPEED +
|
||||
VacuumEntityFeature.BATTERY +
|
||||
VacuumEntityFeature.STATUS +
|
||||
VacuumEntityFeature.LOCATE +
|
||||
VacuumEntityFeature.CLEAN_SPOT +
|
||||
@@ -27,6 +28,8 @@ 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",
|
||||
@@ -38,6 +41,8 @@ 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",
|
||||
@@ -53,7 +58,10 @@ const ENTITIES = [
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME,
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.BATTERY,
|
||||
battery_level: 23,
|
||||
battery_icon: "mdi:battery-20",
|
||||
status: "Returning to dock",
|
||||
},
|
||||
},
|
||||
@@ -88,6 +96,8 @@ 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",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
|
||||
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
|
||||
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import type {
|
||||
LandingPageKeys,
|
||||
@@ -35,6 +34,9 @@ const SCHEDULE_FETCH_OBSERVER_LOGS = 5;
|
||||
|
||||
@customElement("landing-page-logs")
|
||||
class LandingPageLogs extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public localize!: LocalizeFunc<LandingPageKeys>;
|
||||
|
||||
@query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml;
|
||||
|
||||
@query(".logs") private _logElement?: HTMLElement;
|
||||
@@ -42,10 +44,6 @@ class LandingPageLogs extends LitElement {
|
||||
@query("#scroll-bottom-marker")
|
||||
private _scrollBottomMarkerElement?: HTMLElement;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc<LandingPageKeys>;
|
||||
|
||||
@state() private _show = false;
|
||||
|
||||
@state() private _scrolledToBottomController =
|
||||
@@ -65,12 +63,12 @@ class LandingPageLogs extends LitElement {
|
||||
return html`
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._toggleLogDetails}>
|
||||
${this._localize(this._show ? "hide_details" : "show_details")}
|
||||
${this.localize(this._show ? "hide_details" : "show_details")}
|
||||
</ha-button>
|
||||
${
|
||||
this._show
|
||||
? html`<ha-icon-button
|
||||
.label=${this._localize("logs.download_logs")}
|
||||
.label=${this.localize("logs.download_logs")}
|
||||
.path=${mdiDownload}
|
||||
@click=${this._downloadLogs}
|
||||
></ha-icon-button>`
|
||||
@@ -82,14 +80,14 @@ class LandingPageLogs extends LitElement {
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this._localize("logs.fetch_error")}
|
||||
.title=${this.localize("logs.fetch_error")}
|
||||
>
|
||||
<ha-button
|
||||
size="small"
|
||||
variant="danger"
|
||||
@click=${this._startLogStream}
|
||||
>
|
||||
${this._localize("logs.retry")}
|
||||
${this.localize("logs.retry")}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`
|
||||
@@ -117,7 +115,7 @@ class LandingPageLogs extends LitElement {
|
||||
@click=${this._scrollToBottom}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="start"></ha-svg-icon>
|
||||
${this._localize("logs.scroll_down_button")}
|
||||
${this.localize("logs.scroll_down_button")}
|
||||
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="end"></ha-svg-icon>
|
||||
</ha-button>
|
||||
`;
|
||||
|
||||
@@ -1,37 +1,35 @@
|
||||
import { LitElement, css, html, nothing, type CSSResultGroup } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import { type CSSResultGroup, LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type {
|
||||
LandingPageKeys,
|
||||
LocalizeFunc,
|
||||
} from "../../../src/common/translations/localize";
|
||||
import "../../../src/components/ha-alert";
|
||||
import "../../../src/components/ha-button";
|
||||
import type { NetworkInterface } from "../../../src/data/hassio/network";
|
||||
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
|
||||
import "../../../src/components/ha-alert";
|
||||
import {
|
||||
ALTERNATIVE_DNS_SERVERS,
|
||||
setSupervisorNetworkDns,
|
||||
type NetworkInfo,
|
||||
} from "../data/supervisor";
|
||||
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
|
||||
import type { NetworkInterface } from "../../../src/data/hassio/network";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
|
||||
@customElement("landing-page-network")
|
||||
class LandingPageNetwork extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public localize!: LocalizeFunc<LandingPageKeys>;
|
||||
|
||||
@property({ attribute: false }) public networkInfo?: NetworkInfo;
|
||||
|
||||
@property({ type: Boolean }) public error = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc<LandingPageKeys>;
|
||||
|
||||
protected render() {
|
||||
if (this.error) {
|
||||
return html`
|
||||
<ha-alert alert-type="error">
|
||||
<p>${this._localize("network_issue.error_get_network_info")}</p>
|
||||
<p>${this.localize("network_issue.error_get_network_info")}</p>
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
@@ -49,21 +47,19 @@ class LandingPageNetwork extends LitElement {
|
||||
return html`
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this._localize("network_issue.title")}
|
||||
.title=${this.localize("network_issue.title")}
|
||||
>
|
||||
<p>
|
||||
${this._localize("network_issue.description", {
|
||||
${this.localize("network_issue.description", {
|
||||
dns: dnsPrimaryInterfaceNameservers || "?",
|
||||
})}
|
||||
</p>
|
||||
<p>${this._localize("network_issue.resolve_different")}</p>
|
||||
<p>${this.localize("network_issue.resolve_different")}</p>
|
||||
${
|
||||
!dnsPrimaryInterfaceNameservers
|
||||
? html`
|
||||
<p>
|
||||
<b
|
||||
>${this._localize("network_issue.no_primary_interface")}
|
||||
</b>
|
||||
<b>${this.localize("network_issue.no_primary_interface")} </b>
|
||||
</p>
|
||||
`
|
||||
: nothing
|
||||
@@ -76,7 +72,7 @@ class LandingPageNetwork extends LitElement {
|
||||
.index=${key}
|
||||
.disabled=${!dnsPrimaryInterfaceNameservers}
|
||||
@click=${this._setDns}
|
||||
>${this._localize(translationKey)}</ha-button
|
||||
>${this.localize(translationKey)}</ha-button
|
||||
>`
|
||||
)}
|
||||
</div>
|
||||
@@ -122,12 +118,12 @@ class LandingPageNetwork extends LitElement {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
showAlertDialog(this, {
|
||||
title: this._localize("network_issue.failed"),
|
||||
title: this.localize("network_issue.failed"),
|
||||
warning: true,
|
||||
text: `${this._localize(
|
||||
text: `${this.localize(
|
||||
"network_issue.set_dns_failed"
|
||||
)}${err?.message ? ` ${this._localize("network_issue.error")}: ${err.message}` : ""}`,
|
||||
confirmText: this._localize("network_issue.close"),
|
||||
)}${err?.message ? ` ${this.localize("network_issue.error")}: ${err.message}` : ""}`,
|
||||
confirmText: this.localize("network_issue.close"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import "../../src/components/ha-spinner";
|
||||
import "../../src/components/ha-svg-icon";
|
||||
import "../../src/components/progress/ha-progress-bar";
|
||||
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
|
||||
import { provideLiteI18nMixin } from "../../src/mixins/provide-lite-i18n-mixin";
|
||||
import "../../src/onboarding/onboarding-welcome-links";
|
||||
import { onBoardingStyles } from "../../src/onboarding/styles";
|
||||
import { haStyle } from "../../src/resources/styles";
|
||||
@@ -29,7 +28,7 @@ const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
|
||||
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
|
||||
|
||||
@customElement("ha-landing-page")
|
||||
class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
class HaLandingPage extends LandingPageBaseElement {
|
||||
@property({ attribute: false }) public translationFragment = "landing-page";
|
||||
|
||||
@state() private _supervisorError = false;
|
||||
@@ -83,6 +82,7 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
networkIssue || this._networkInfoError
|
||||
? html`
|
||||
<landing-page-network
|
||||
.localize=${this.localize}
|
||||
.networkInfo=${this._networkInfo}
|
||||
.error=${this._networkInfoError}
|
||||
@dns-set=${this._fetchSupervisorInfo}
|
||||
@@ -103,11 +103,13 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
: nothing
|
||||
}
|
||||
<landing-page-logs
|
||||
.localize=${this.localize}
|
||||
@landing-page-error=${this._showError}
|
||||
></landing-page-logs>
|
||||
</div>
|
||||
</ha-card>
|
||||
<onboarding-welcome-links
|
||||
.localize=${this.localize}
|
||||
.mobileApp=${this._mobileApp}
|
||||
></onboarding-welcome-links>
|
||||
<div class="footer">
|
||||
|
||||
+41
-44
@@ -39,20 +39,20 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.5",
|
||||
"@babel/runtime": "8.0.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.0",
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@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.2",
|
||||
"@codemirror/state": "6.7.4",
|
||||
"@codemirror/view": "6.43.11",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.8",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.6.1",
|
||||
"@formatjs/intl-datetimeformat": "7.6.0",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
@@ -67,7 +67,7 @@
|
||||
"@fullcalendar/list": "6.1.21",
|
||||
"@fullcalendar/luxon3": "6.1.21",
|
||||
"@fullcalendar/timegrid": "6.1.21",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.1",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -75,8 +75,6 @@
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.5.0",
|
||||
@@ -84,39 +82,40 @@
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.3.2",
|
||||
"@tsparticles/preset-links": "4.3.2",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"barcode-detector": "3.2.2",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.1",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.6.3",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.3",
|
||||
"hls.js": "1.6.17",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.4.1",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"js-yaml": "5.2.3",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.12",
|
||||
"marked": "18.0.9",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.1",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
"punycode": "2.3.1",
|
||||
@@ -139,22 +138,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.5",
|
||||
"@babel/core": "8.0.1",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.1",
|
||||
"@babel/preset-env": "8.0.5",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
"@lokalise/node-api": "16.4.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.4",
|
||||
"@rspack/core": "2.2.3",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.8",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -162,21 +161,20 @@
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.5",
|
||||
"@types/luxon": "3.7.4",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@versatiles/style": "5.13.1",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.28.9",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.10.0",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -186,24 +184,23 @@
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.5",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.12.0",
|
||||
"globals": "17.9.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.0.1",
|
||||
"jszip": "3.10.2",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.5.1",
|
||||
"lint-staged": "17.3.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.2.0",
|
||||
"minify-literals": "2.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
@@ -213,10 +210,9 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.70.0",
|
||||
"vite": "8.3.0",
|
||||
"typescript-eslint": "8.66.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.0",
|
||||
"vitest": "4.1.10",
|
||||
"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"
|
||||
@@ -227,11 +223,12 @@
|
||||
"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.21.0"
|
||||
"node": "24.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260826.0"
|
||||
version = "20260729.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -27,7 +27,6 @@ const ALLOWED_LICENSES = new Set([
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
|
||||
@@ -8,4 +8,4 @@ set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-device-classes
|
||||
./node_modules/.bin/gulp gen-numeric-device-classes
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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
|
||||
+18
-20
@@ -23,7 +23,6 @@ import type {
|
||||
} from "../data/data_entry_flow";
|
||||
import "./ha-auth-form";
|
||||
import type { HaAuthForm } from "./ha-auth-form";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
|
||||
type State = "loading" | "error" | "step";
|
||||
|
||||
@@ -37,14 +36,12 @@ export class HaAuthFlow extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public oauth2State?: string;
|
||||
|
||||
@property({ attribute: false }) public localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public step?: DataEntryFlowStep;
|
||||
|
||||
@property({ attribute: false }) public initStoreToken = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state() private _storeToken = false;
|
||||
|
||||
@state() private _state: State = "loading";
|
||||
@@ -187,8 +184,8 @@ export class HaAuthFlow extends LitElement {
|
||||
>
|
||||
${
|
||||
this.step.type === "form"
|
||||
? this._localize("ui.panel.page-authorize.form.next")
|
||||
: this._localize("ui.panel.page-authorize.form.start_over")
|
||||
? this.localize("ui.panel.page-authorize.form.next")
|
||||
: this.localize("ui.panel.page-authorize.form.start_over")
|
||||
}
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -196,20 +193,20 @@ export class HaAuthFlow extends LitElement {
|
||||
case "error":
|
||||
return html`
|
||||
<ha-alert alert-type="error">
|
||||
${this._localize("ui.panel.page-authorize.form.error", {
|
||||
${this.localize("ui.panel.page-authorize.form.error", {
|
||||
error: this._errorMessage,
|
||||
})}
|
||||
</ha-alert>
|
||||
<div class="action">
|
||||
<ha-button @click=${this._startOver}>
|
||||
${this._localize("ui.panel.page-authorize.form.start_over")}
|
||||
${this.localize("ui.panel.page-authorize.form.start_over")}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
case "loading":
|
||||
return html`
|
||||
<ha-alert alert-type="info">
|
||||
${this._localize("ui.panel.page-authorize.form.working")}
|
||||
${this.localize("ui.panel.page-authorize.form.working")}
|
||||
</ha-alert>
|
||||
`;
|
||||
default:
|
||||
@@ -221,8 +218,8 @@ export class HaAuthFlow extends LitElement {
|
||||
switch (step.type) {
|
||||
case "abort":
|
||||
return html`
|
||||
${this._localize("ui.panel.page-authorize.abort_intro")}:
|
||||
${this._localize(
|
||||
${this.localize("ui.panel.page-authorize.abort_intro")}:
|
||||
${this.localize(
|
||||
`ui.panel.page-authorize.form.providers.${step.handler[0]}.abort.${step.reason}`
|
||||
)}
|
||||
`;
|
||||
@@ -231,14 +228,15 @@ export class HaAuthFlow extends LitElement {
|
||||
<h1>
|
||||
${
|
||||
!["select_mfa_module", "mfa"].includes(step.step_id)
|
||||
? this._localize("ui.panel.page-authorize.welcome_home")
|
||||
: this._localize("ui.panel.page-authorize.just_checking")
|
||||
? this.localize("ui.panel.page-authorize.welcome_home")
|
||||
: this.localize("ui.panel.page-authorize.just_checking")
|
||||
}
|
||||
</h1>
|
||||
${this._computeStepDescription(step)}
|
||||
${keyed(
|
||||
step.step_id,
|
||||
html`<ha-auth-form
|
||||
.localize=${this.localize}
|
||||
.data=${this._stepData!}
|
||||
.schema=${autocompleteLoginFields(step.data_schema)}
|
||||
.error=${step.errors}
|
||||
@@ -258,7 +256,7 @@ export class HaAuthFlow extends LitElement {
|
||||
.checked=${this._storeToken}
|
||||
@change=${this._storeTokenChanged}
|
||||
>
|
||||
${this._localize("ui.panel.page-authorize.store_token")}
|
||||
${this.localize("ui.panel.page-authorize.store_token")}
|
||||
</ha-checkbox>
|
||||
`
|
||||
: ""
|
||||
@@ -268,7 +266,7 @@ export class HaAuthFlow extends LitElement {
|
||||
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>${this._localize("ui.panel.page-authorize.forgot_password")}</a
|
||||
>${this.localize("ui.panel.page-authorize.forgot_password")}</a
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
@@ -338,13 +336,13 @@ export class HaAuthFlow extends LitElement {
|
||||
private _computeStepDescription(step: DataEntryFlowStepForm) {
|
||||
const resourceKey =
|
||||
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.description` as const;
|
||||
return this._localize(resourceKey, step.description_placeholders);
|
||||
return this.localize(resourceKey, step.description_placeholders);
|
||||
}
|
||||
|
||||
private _computeLabelCallback(step: DataEntryFlowStepForm) {
|
||||
// Returns a callback for ha-form to calculate labels per schema object
|
||||
return (schema) =>
|
||||
this._localize(
|
||||
this.localize(
|
||||
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.data.${schema.name}`
|
||||
);
|
||||
}
|
||||
@@ -352,13 +350,13 @@ export class HaAuthFlow extends LitElement {
|
||||
private _computeErrorCallback(step: DataEntryFlowStepForm) {
|
||||
// Returns a callback for ha-form to calculate error messages
|
||||
return (error) =>
|
||||
this._localize(
|
||||
this.localize(
|
||||
`ui.panel.page-authorize.form.providers.${step.handler[0]}.error.${error}`
|
||||
);
|
||||
}
|
||||
|
||||
private _unknownError() {
|
||||
return this._localize("ui.panel.page-authorize.form.unknown_error");
|
||||
return this.localize("ui.panel.page-authorize.form.unknown_error");
|
||||
}
|
||||
|
||||
private _startOver() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable lit/prefer-static-styles */
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { HaForm } from "../components/ha-form/ha-form";
|
||||
import "./ha-auth-form-string";
|
||||
@@ -10,13 +9,11 @@ const localizeBaseKey = "ui.panel.page-authorize.form";
|
||||
|
||||
@customElement("ha-auth-form")
|
||||
export class HaAuthForm extends HaForm {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
protected getFormProperties(): Record<string, any> {
|
||||
return {
|
||||
localize: this._localize,
|
||||
localize: this.localize,
|
||||
localizeBaseKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import "../components/ha-svg-icon";
|
||||
import type { AuthProvider, AuthUrlSearchParams } from "../data/auth";
|
||||
import { fetchAuthProviders } from "../data/auth";
|
||||
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
|
||||
import { provideLiteI18nMixin } from "../mixins/provide-lite-i18n-mixin";
|
||||
import { registerServiceWorker } from "../util/register-service-worker";
|
||||
import "./ha-auth-flow";
|
||||
|
||||
@@ -24,9 +23,7 @@ const appNames = {
|
||||
};
|
||||
|
||||
@customElement("ha-authorize")
|
||||
export class HaAuthorize extends provideLiteI18nMixin(
|
||||
litLocalizeLiteMixin(LitElement)
|
||||
) {
|
||||
export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
|
||||
@property({ attribute: false }) public clientId?: string;
|
||||
|
||||
@property({ attribute: false }) public redirectUri?: string;
|
||||
@@ -186,12 +183,14 @@ export class HaAuthorize extends provideLiteI18nMixin(
|
||||
.redirectUri=${this.redirectUri}
|
||||
.oauth2State=${this.oauth2State}
|
||||
.authProvider=${this._authProvider}
|
||||
.localize=${this.localize}
|
||||
.initStoreToken=${this._preselectStoreToken}
|
||||
></ha-auth-flow>
|
||||
${
|
||||
inactiveProviders!.length > 0
|
||||
? html`
|
||||
<ha-pick-auth-provider
|
||||
.localize=${this.localize}
|
||||
.clientId=${this.clientId}
|
||||
.authProviders=${inactiveProviders!}
|
||||
@pick-auth-provider=${this._handleAuthProviderPick}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-icon-next";
|
||||
@@ -21,15 +20,13 @@ declare global {
|
||||
export class HaPickAuthProvider extends LitElement {
|
||||
@property({ attribute: false }) public authProviders: AuthProvider[] = [];
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public localize!: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<h3>
|
||||
<span
|
||||
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
|
||||
>${this.localize("ui.panel.page-authorize.pick_auth_provider")}</span
|
||||
>
|
||||
</h3>
|
||||
<ha-list>
|
||||
|
||||
@@ -62,16 +62,12 @@ export function computeCssColor(color: string): string {
|
||||
/**
|
||||
* Get a color from document's styles
|
||||
* @param color - Named theme color (examples: `red`, `primary-text`)
|
||||
* @param style - Styles to resolve against, defaults to the document body
|
||||
* @returns Resolved color; initial color if not found in the styles
|
||||
* @returns Resolved color; initial color if not found in document's styles
|
||||
*/
|
||||
export function resolveThemeColor(
|
||||
color: string,
|
||||
style?: CSSStyleDeclaration
|
||||
): string {
|
||||
export function resolveThemeColor(color: string): string {
|
||||
const cssColor = computeCssVariableName(color);
|
||||
if (cssColor.startsWith("--")) {
|
||||
const resolved = (style ?? getComputedStyle(document.body))
|
||||
const resolved = getComputedStyle(document.body)
|
||||
.getPropertyValue(cssColor)
|
||||
.trim();
|
||||
return resolved || color;
|
||||
|
||||
+5
-22
@@ -1,4 +1,4 @@
|
||||
import { parse, wcagLuminance, wcagContrast } from "culori";
|
||||
import { wcagLuminance, wcagContrast } from "culori";
|
||||
import { theme2hex } from "./convert-color";
|
||||
|
||||
/**
|
||||
@@ -51,28 +51,11 @@ export const getRGBContrastRatio = (
|
||||
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Tells whether a color can be measured, which a CSS function that is passed
|
||||
* through unevaluated cannot, and whether it covers what is behind it
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to check
|
||||
* @returns Whether a contrast against this color says anything
|
||||
*/
|
||||
export const isOpaqueColor = (color: string): boolean => {
|
||||
const parsed = parse(color.trim());
|
||||
return parsed !== undefined && (parsed.alpha ?? 1) === 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a contrasted color (black or white) for another color
|
||||
* Returns a contrasted color (black or white) based on the luminance of another color
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
|
||||
* @returns HEX color, whichever of black and white has the higher contrast ratio
|
||||
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
|
||||
*/
|
||||
export const getContrastedColorHex = (color: string): string => {
|
||||
const hex = theme2hex(color.trim());
|
||||
// culori throws on a color it cannot read
|
||||
if (!parse(hex)) {
|
||||
return "#ffffff";
|
||||
}
|
||||
return wcagContrast(hex, "#000000") >= wcagContrast(hex, "#ffffff")
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
const lum = wcagLuminance(theme2hex(color));
|
||||
return lum > 0.5 ? "#000000" : "#ffffff";
|
||||
};
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
import {
|
||||
isClientCondition,
|
||||
isDisabledCondition,
|
||||
isEntityReference,
|
||||
isLogicalCondition,
|
||||
logicalChildren,
|
||||
} from "./translate";
|
||||
|
||||
// Combinators: true / false / undefined (unknown)
|
||||
const andOf = (values: (boolean | undefined)[]): boolean | undefined => {
|
||||
let unknown = false;
|
||||
for (const value of values) {
|
||||
if (value === false) return false;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : true;
|
||||
};
|
||||
|
||||
const orOf = (values: (boolean | undefined)[]): boolean | undefined => {
|
||||
let unknown = false;
|
||||
for (const value of values) {
|
||||
if (value === true) return true;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when `checkConditionsMet` matches core for this leaf, so we can
|
||||
* show a result before `subscribe_condition` replies.
|
||||
*/
|
||||
const isLocallyEvaluableServerLeaf = (
|
||||
condition: VisibilityCondition,
|
||||
states: HomeAssistant["states"],
|
||||
context: ConditionContext
|
||||
): boolean => {
|
||||
const type = "condition" in condition ? condition.condition : "state";
|
||||
if (type !== "state" && type !== "numeric_state") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!("entity_id" in condition)) {
|
||||
// Empty `entity: ""` falls back to the host entity, like checkStateCondition.
|
||||
const target =
|
||||
("entity" in condition ? condition.entity : undefined) ||
|
||||
context.entity_id;
|
||||
return !!target && target in states;
|
||||
}
|
||||
|
||||
const core = condition as {
|
||||
entity_id?: unknown;
|
||||
attribute?: unknown;
|
||||
for?: unknown;
|
||||
match?: unknown;
|
||||
value_template?: unknown;
|
||||
state?: unknown;
|
||||
above?: unknown;
|
||||
below?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof core.entity_id !== "string" ||
|
||||
!(core.entity_id in states) ||
|
||||
core.attribute !== undefined ||
|
||||
core.for !== undefined ||
|
||||
core.match !== undefined ||
|
||||
core.value_template !== undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (type === "numeric_state") {
|
||||
const bounds = [core.above, core.below].filter((b) => b !== undefined);
|
||||
return bounds.length > 0 && bounds.every((b) => typeof b === "number");
|
||||
}
|
||||
return !ensureArray(core.state as string | string[] | undefined)?.some(
|
||||
isEntityReference
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluate the parts of a visibility tree the client can know exactly.
|
||||
* Unknown leaves stay unknown (`not: [template]` is not treated as visible).
|
||||
* Returns `undefined` when the result still depends on core.
|
||||
*/
|
||||
export const evaluateConditionsLocally = (
|
||||
conditions: VisibilityCondition[],
|
||||
hass: HomeAssistant,
|
||||
context: ConditionContext
|
||||
): boolean | undefined => {
|
||||
const evaluateLeaf = (condition: VisibilityCondition): boolean => {
|
||||
try {
|
||||
return checkConditionsMet([condition as Condition], hass, context);
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateNode = (
|
||||
condition: VisibilityCondition
|
||||
): boolean | undefined => {
|
||||
// Template `enabled` is only known to core; the local evaluator ignores it.
|
||||
if ("enabled" in condition && typeof condition.enabled !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
if (isLogicalCondition(condition)) {
|
||||
// Missing `conditions` is treated as true, matching checkAnd/Or/NotCondition.
|
||||
if (condition.conditions === undefined) {
|
||||
return true;
|
||||
}
|
||||
const values = logicalChildren(condition)
|
||||
.filter((child) => !isDisabledCondition(child))
|
||||
.map(evaluateNode);
|
||||
if (condition.condition === "or") {
|
||||
return orOf(values);
|
||||
}
|
||||
const all = andOf(values);
|
||||
// Lovelace `not` is NOT(AND of children).
|
||||
return condition.condition === "not"
|
||||
? all === undefined
|
||||
? undefined
|
||||
: !all
|
||||
: all;
|
||||
}
|
||||
if (
|
||||
isClientCondition(condition) ||
|
||||
isLocallyEvaluableServerLeaf(condition, hass.states, context)
|
||||
) {
|
||||
return evaluateLeaf(condition);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Top-level list is AND. Skip `enabled: false` nodes, as core does.
|
||||
return andOf(
|
||||
conditions.filter((c) => !isDisabledCondition(c)).map(evaluateNode)
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,17 @@
|
||||
import type {
|
||||
Condition,
|
||||
TimeCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
|
||||
/**
|
||||
* Extract media queries from conditions recursively
|
||||
*/
|
||||
export function extractMediaQueries(
|
||||
conditions: VisibilityCondition[]
|
||||
): string[] {
|
||||
export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
return conditions.reduce<string[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractMediaQueries(ensureArray(c.conditions)));
|
||||
array.push(...extractMediaQueries(c.conditions));
|
||||
}
|
||||
if (
|
||||
"condition" in c &&
|
||||
c.condition === "screen" &&
|
||||
"media_query" in c &&
|
||||
c.media_query
|
||||
) {
|
||||
if (c.condition === "screen" && c.media_query) {
|
||||
array.push(c.media_query);
|
||||
}
|
||||
return array;
|
||||
@@ -30,15 +22,14 @@ export function extractMediaQueries(
|
||||
* Extract time conditions from conditions recursively
|
||||
*/
|
||||
export function extractTimeConditions(
|
||||
conditions: VisibilityCondition[]
|
||||
conditions: Condition[]
|
||||
): TimeCondition[] {
|
||||
return conditions.reduce<TimeCondition[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractTimeConditions(ensureArray(c.conditions)));
|
||||
array.push(...extractTimeConditions(c.conditions));
|
||||
}
|
||||
if ("condition" in c && c.condition === "time") {
|
||||
// Dashboard `time` is always the client lovelace shape.
|
||||
array.push(c as TimeCondition);
|
||||
if (c.condition === "time") {
|
||||
array.push(c);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { listenMediaQuery } from "../dom/media_query";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type {
|
||||
TimeCondition,
|
||||
VisibilityCondition,
|
||||
Condition,
|
||||
ConditionContext,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import { extractMediaQueries, extractTimeConditions } from "./extract";
|
||||
import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
|
||||
@@ -15,65 +16,95 @@ import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
const MAX_TIMEOUT_DELAY = 2147483647;
|
||||
|
||||
/**
|
||||
* Fire onChange at the next time-condition boundary, then reschedule.
|
||||
* Delays past the setTimeout max are capped and retried without firing.
|
||||
* Helper to setup media query listeners for conditional visibility
|
||||
*/
|
||||
function scheduleTimeBoundaryListener(
|
||||
getHass: () => HomeAssistant,
|
||||
timeCondition: Omit<TimeCondition, "condition">,
|
||||
export function setupMediaQueryListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onBoundary: () => void
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const mediaQueries = extractMediaQueries(conditions);
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
// Read hass here so a timezone change applies at the next boundary.
|
||||
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
|
||||
if (mediaQueries.length === 0) return;
|
||||
|
||||
if (delay === undefined) return;
|
||||
// Optimization for single media query
|
||||
const hasOnlyMediaQuery =
|
||||
conditions.length === 1 &&
|
||||
conditions[0].condition === "screen" &&
|
||||
!!conditions[0].media_query;
|
||||
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
onBoundary();
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
const unsub = listenMediaQuery(mediaQuery, (matches) => {
|
||||
if (hasOnlyMediaQuery) {
|
||||
onUpdate(matches);
|
||||
} else {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
addListener(unsub);
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the client-evaluated parts of a condition tree — `screen` media
|
||||
* queries and `time` boundaries — and invoke `onChange` whenever one of them
|
||||
* could have flipped.
|
||||
*
|
||||
* This does not evaluate the conditions itself: the caller recombines client
|
||||
* and server results on notification. Used by `ConditionEvaluatorController`,
|
||||
* which merges these client signals with the results of `subscribe_condition`
|
||||
* subscriptions.
|
||||
* Helper to setup time-based listeners for conditional visibility
|
||||
*/
|
||||
export function observeConditionChanges(
|
||||
conditions: VisibilityCondition[],
|
||||
getHass: () => HomeAssistant,
|
||||
export function setupTimeListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onChange: () => void
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
extractMediaQueries(conditions).forEach((mediaQuery) => {
|
||||
addListener(listenMediaQuery(mediaQuery, () => onChange()));
|
||||
});
|
||||
const timeConditions = extractTimeConditions(conditions);
|
||||
|
||||
extractTimeConditions(conditions).forEach((timeCondition) => {
|
||||
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, onChange);
|
||||
if (timeConditions.length === 0) return;
|
||||
|
||||
timeConditions.forEach((timeCondition) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
const delay = calculateNextTimeUpdate(hass, timeCondition);
|
||||
|
||||
if (delay === undefined) return;
|
||||
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up all condition listeners (media query, time) for conditional visibility.
|
||||
*/
|
||||
export function setupConditionListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user