mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-26 22:41:29 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18de8b13d3 | |||
| 39e70ff315 | |||
| 14fd9114d6 | |||
| 406a0b2bad | |||
| 4c86785d21 | |||
| 2aeb522eed | |||
| bef1c0def6 | |||
| f7e5bdb2a0 | |||
| de59237cfb | |||
| f778572b89 | |||
| d2c753b589 | |||
| 8fbb38a25d | |||
| 2f6c1388e8 | |||
| ced5ffdd18 | |||
| 5a6a16ed27 | |||
| 3d1ed43edc | |||
| d682830a3e | |||
| 863aad24fc | |||
| 3284633ac9 | |||
| 8dd54e0aa8 | |||
| 90a79d458f | |||
| 34dba7ea5a | |||
| e76e7d1064 | |||
| b6675159ae | |||
| 9d96d0b4aa | |||
| d900cc70c5 | |||
| 837baff26c | |||
| 43b4e247af | |||
| 17def3c5ee | |||
| ba15879662 | |||
| 0046f72641 | |||
| f66ba11705 | |||
| bded4341df | |||
| 7a6091b5e2 | |||
| 6d12672c3e | |||
| 495562d748 | |||
| 5cf7fb3a3d | |||
| b6813ce060 | |||
| 3a060feb82 | |||
| ab39c4dd4a | |||
| 82167a81f7 |
@@ -23,11 +23,10 @@ Dialog implementation requirements:
|
||||
|
||||
- Use `ha-dialog`.
|
||||
- Use `DialogMixin`, which implements `HassDialogNext<T>`, for new dialogs. See `src/dialogs/dialog-mixin.ts`.
|
||||
- Read dialog parameters from the mixin's `params` property and render the dialog open. Return `nothing` while required parameters are absent.
|
||||
- Call the mixin's `closeDialog()` to close a new dialog. The mixin handles the `closed` event, fires `dialog-closed`, and removes the host element.
|
||||
- Existing dialogs may implement the legacy `HassDialog<T>` interface from `src/dialogs/make-dialog-manager.ts`.
|
||||
- Use `@state() private _open = false` to control visibility.
|
||||
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
|
||||
- Return `nothing` while required params are absent.
|
||||
- Fire `dialog-closed` in the close handler.
|
||||
- Preserve the existing `showDialog()`, open-state, and close-event lifecycle when maintaining a legacy dialog; do not copy that lifecycle into a `DialogMixin` dialog.
|
||||
- Use `header-title` and `header-subtitle` for simple header text.
|
||||
- Use slots when standard header attributes are not enough.
|
||||
- Use `ha-dialog-footer` with `primaryAction` and `secondaryAction` slots.
|
||||
@@ -64,7 +63,7 @@ Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, v
|
||||
.data=${this._data}
|
||||
.schema=${this._schema}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||
.computeLabel=${(schema) => this._localize(`ui.panel.${schema.name}`)}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
```
|
||||
@@ -78,10 +77,16 @@ Use `ha-alert` for user-visible status messaging.
|
||||
- Slots: `icon` for custom leading icon, `action` for custom action content.
|
||||
- Content is announced by screen readers when dynamically displayed.
|
||||
|
||||
```html
|
||||
<ha-alert alert-type="error">Error message</ha-alert>
|
||||
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```ts
|
||||
html`
|
||||
<ha-alert alert-type="error">${this._localize("ui.example.error")}</ha-alert>
|
||||
<ha-alert alert-type="warning" .title=${this._localize("ui.example.warning")}>
|
||||
${this._localize("ui.example.description")}
|
||||
</ha-alert>
|
||||
<ha-alert alert-type="success" dismissable>
|
||||
${this._localize("ui.example.success")}
|
||||
</ha-alert>
|
||||
`;
|
||||
```
|
||||
|
||||
## Shortcuts And Tooltips
|
||||
|
||||
@@ -23,27 +23,27 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
|
||||
|
||||
## Context Selection
|
||||
|
||||
| Context | Replaces |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
|
||||
| `registriesContext` | all four registries together |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | entity and attribute formatters |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
| Context | Replaces |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
|
||||
| `registriesContext` | all four registries together |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | entity and attribute formatters |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.debugConnection`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callApiRaw`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, and `manifestsContext`.
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
|
||||
|
||||
The single-field contexts such as `localizeContext`, `themesContext`, and `userContext` are deprecated. Use grouped contexts instead.
|
||||
|
||||
## Consumption Patterns
|
||||
|
||||
Use entity-scoped helpers when the component watches an entity id held on the host:
|
||||
Use entity-scoped helpers when the component watches an entity ID held on the host:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
@@ -56,6 +56,13 @@ private _entity?: EntityRegistryDisplayEntry;
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
Use `consumeEntityStates` when the host property contains one or more entity IDs. It filters missing entities and preserves the previous record when none of the selected entities changed.
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityStates({ entityIdPath: ["_config", "entities"] })
|
||||
private _stateObjs?: Record<string, HassEntity>;
|
||||
```
|
||||
|
||||
For a single field from a grouped context, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@@ -65,7 +72,7 @@ For a single field from a grouped context, pair `@consume` with `@transform`:
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity id. `consumeEntityState` only watches the first path segment.
|
||||
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity ID. `consumeEntityState` and `consumeEntityStates` only watch the first path segment.
|
||||
|
||||
To consume a whole group untransformed, omit `@transform` and type the field as `ContextType<typeof statesContext>` or the matching context type.
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# Gallery Agent Instructions
|
||||
---
|
||||
name: ha-frontend-gallery
|
||||
description: Home Assistant frontend gallery structure, pages, demos, content, and verification. Use when changing files under gallery/, including gallery markdown, TypeScript demos, sidebar entries, mock data, page generation, or gallery builds.
|
||||
---
|
||||
|
||||
This file applies to all files under `gallery/`. Follow the root `AGENTS.md` for repository-wide Home Assistant frontend, TypeScript, Lit, accessibility, and copy standards. This file adds gallery-specific structure, page, demo, and verification guidance.
|
||||
# HA Frontend Gallery
|
||||
|
||||
Use this skill for all work under `gallery/`. Follow the persistent repository guidance in `AGENTS.md` and load the matching specialist skills alongside this gallery-specific guidance.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -13,7 +18,7 @@ yarn lint # ESLint, Prettier, TypeScript, and Lit checks
|
||||
yarn lint:types # TypeScript compiler, without file arguments
|
||||
```
|
||||
|
||||
Never run `yarn lint:types` or `tsc` with file arguments. See the root `AGENTS.md` for the generated `.js` file risk.
|
||||
Never run `yarn lint:types` or `tsc` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -26,36 +31,36 @@ The gallery is a developer and designer reference for Home Assistant frontend UI
|
||||
|
||||
## Structure
|
||||
|
||||
- `sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
|
||||
- `script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
|
||||
- `script/build_gallery`: Wrapper for the `build-gallery` gulp task.
|
||||
- `src/entrypoint.js`: Creates the `<ha-gallery>` shell.
|
||||
- `src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
|
||||
- `src/html/index.html.template`: HTML template used by the gallery build.
|
||||
- `src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
|
||||
- `src/pages/<category>/<page>.ts`: Optional live demo module for the same page id.
|
||||
- `src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
|
||||
- `src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
|
||||
- `public/`: Static assets copied into the gallery output.
|
||||
- `gallery/sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
|
||||
- `gallery/script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
|
||||
- `gallery/script/build_gallery`: Wrapper for the `build-gallery` gulp task.
|
||||
- `gallery/src/entrypoint.js`: Creates the `<ha-gallery>` shell.
|
||||
- `gallery/src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
|
||||
- `gallery/src/html/index.html.template`: HTML template used by the gallery build.
|
||||
- `gallery/src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
|
||||
- `gallery/src/pages/<category>/<page>.ts`: Optional live demo module for the same page ID.
|
||||
- `gallery/src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
|
||||
- `gallery/src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
|
||||
- `gallery/public/`: Static assets copied into the gallery output.
|
||||
|
||||
## Page Model
|
||||
|
||||
Gallery pages are generated by `gather-gallery-pages` in `build-scripts/gulp/gallery.js`.
|
||||
|
||||
- A page id is the path under `src/pages/` without the extension, like `components/ha-button`.
|
||||
- A `.markdown` file and a `.ts` file with the same page id become one gallery page.
|
||||
- A page ID is the path under `gallery/src/pages/` without the extension, like `components/ha-button`.
|
||||
- A `.markdown` file and a `.ts` file with the same page ID become one gallery page.
|
||||
- A page may have only markdown, only a TypeScript demo, or both.
|
||||
- Markdown can contain YAML frontmatter with `title` and optional `subtitle`.
|
||||
- Markdown that contains only frontmatter contributes metadata without rendering a description block.
|
||||
- TypeScript demo modules are dynamically imported for side effects when the page is opened.
|
||||
- A demo module must define a custom element named `demo-${category}-${page}` with slashes replaced by hyphens, like `demo-components-ha-button` for `components/ha-button`.
|
||||
- `ha-gallery.ts` renders that element with `dynamicElement()` based on the current page id.
|
||||
- `gallery/src/ha-gallery.ts` renders that element with `dynamicElement()` based on the current page ID.
|
||||
|
||||
## Sidebar
|
||||
|
||||
Use `sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
|
||||
Use `gallery/sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
|
||||
|
||||
- `category` must match the first directory name under `src/pages/`.
|
||||
- `category` must match the first directory name under `gallery/src/pages/`.
|
||||
- `header` is the section label shown in the drawer.
|
||||
- `pages` is optional. When present, listed pages keep that exact order.
|
||||
- Pages in a category that are not listed are appended alphabetically after the listed pages.
|
||||
@@ -83,20 +88,20 @@ Use markdown pages for explanations, design guidance, API notes, and copy standa
|
||||
- Use fenced code blocks with a language tag for copyable examples.
|
||||
- Keep examples short and focused on the behavior being documented.
|
||||
- Prefer real component names and attributes over prose-only descriptions.
|
||||
- Use Home Assistant terminology from the root `AGENTS.md`.
|
||||
- For remove/delete and add/create wording, follow `src/pages/misc/remove-delete-add-create.markdown`.
|
||||
- Use Home Assistant terminology from `ha-frontend-user-facing-text`.
|
||||
- For remove/delete and add/create wording, follow `gallery/src/pages/misc/remove-delete-add-create.markdown`.
|
||||
|
||||
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, keep those strings aligned with the root localization and copy guidance.
|
||||
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, follow the localization and copy guidance in `ha-frontend-user-facing-text`.
|
||||
|
||||
## Demo Components
|
||||
|
||||
Use TypeScript demo pages for interactive or stateful examples.
|
||||
|
||||
- Import production components from `../../../src/...` or the correct relative path from the demo file.
|
||||
- Import production components from `src/` using the correct relative path from the demo file.
|
||||
- Import reusable gallery helpers from `gallery/src/components/` when they already model the pattern.
|
||||
- Use `demo-card` and `demo-cards` for Lovelace card examples that render YAML card configs.
|
||||
- Use `demo-more-info` and `demo-more-infos` for more-info dialog examples.
|
||||
- Use shared mock data from `src/data/` instead of repeating large fake state objects inline.
|
||||
- Use shared mock data from `gallery/src/data/` instead of repeating large fake state objects inline.
|
||||
- Show meaningful states, such as loading, unavailable, empty, error, active, inactive, and disabled when relevant.
|
||||
- Check responsive behavior and the gallery RTL toggle when layout or direction-sensitive UI changes.
|
||||
- Keep unavoidable casts or loose demo parsing local to the demo helper or demo page.
|
||||
@@ -105,7 +110,7 @@ The gallery ESLint config allows `console` for gallery diagnostics. Do not copy
|
||||
|
||||
## Content Standards
|
||||
|
||||
The root copy standards still apply: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
|
||||
Follow the detailed copy standards in `ha-frontend-user-facing-text`: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
|
||||
|
||||
- Use `Home Assistant` in full, not `HA` or `HASS`.
|
||||
- Use `integration` instead of `component` for product concepts.
|
||||
@@ -65,9 +65,9 @@ The app suite uses a stripped-down harness for e2e. Demo and gallery use their n
|
||||
|
||||
## Benchmarks
|
||||
|
||||
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
|
||||
For chart data transforms such as history, statistics, energy, and downsampling, read and follow the complete workflow in `test/benchmarks/README.md` before making benchmark or optimization changes.
|
||||
|
||||
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
|
||||
That workflow owns the baseline, noise analysis, guardrails, acceptance thresholds, and reporting requirements. In particular, optimizations must keep output bit-identical; never update snapshots or modify fixtures to make an optimization pass.
|
||||
|
||||
## Verification Selection
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Use this skill for all user-facing text, translations, labels, buttons, dialog c
|
||||
- Give translators enough context through key naming and placeholders.
|
||||
|
||||
```ts
|
||||
this.hass.localize("ui.panel.config.updates.updates_refreshed", {
|
||||
this._localize("ui.panel.config.updates.updates_refreshed", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run actionlint
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Prepare dependencies
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
|
||||
@@ -27,17 +27,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Copilot Setup Steps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Skip fetching nightly translations
|
||||
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
- 2
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -278,7 +278,7 @@ jobs:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
pull-requests: write # To label and comment on pull requests
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -26,17 +26,17 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
|
||||
uses: home-assistant/actions/helpers/verify-version@e3fb68ebda13d88a0d695082f471ba2c83d025fb # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
script/release
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
|
||||
|
||||
For gallery-specific documentation, demos, page structure, and examples, read `gallery/AGENTS.md` when working under `gallery/`.
|
||||
For the standalone demo — including how to open a specific demo configuration or page via URL — read `demo/AGENTS.md`.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
@@ -46,6 +46,7 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
|
||||
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Demo Agent Instructions
|
||||
|
||||
This file applies to all files under `demo/`. Follow the root `AGENTS.md` for repository-wide standards.
|
||||
|
||||
The demo is the full Home Assistant frontend running against a mocked backend (published at https://demo.home-assistant.io). It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
|
||||
|
||||
## Running the demo
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
yarn dev:demo # dev server on http://localhost:8090
|
||||
yarn dev:demo --background # detached; also supports --status/--stop/--logs
|
||||
```
|
||||
|
||||
## Opening a specific demo
|
||||
|
||||
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, e.g. `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default (the first entry).
|
||||
|
||||
## Opening a specific page
|
||||
|
||||
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
|
||||
|
||||
```
|
||||
http://localhost:8090/?demo=<slug>#/<path>
|
||||
```
|
||||
|
||||
Useful paths:
|
||||
|
||||
- `/lovelace/0` — the selected demo's dashboard (also the default when no hash is given)
|
||||
- `/energy` — energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, `/energy/now`
|
||||
- `/map`, `/history`, `/todo`, `/config` — other sidebar panels
|
||||
|
||||
Example — the water tab of the energy dashboard of the second demo:
|
||||
|
||||
```
|
||||
http://localhost:8090/?demo=<second slug>#/energy/water
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/ha-demo.ts`: Root element; sets up all backend mocks.
|
||||
- `src/configs/<slug>/`: One directory per demo configuration (entities, dashboard, theme).
|
||||
- `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
|
||||
- `src/stubs/`: Mocked WebSocket/REST APIs.
|
||||
- `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -1,6 +1,9 @@
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
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 { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
|
||||
@@ -12,33 +15,53 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
|
||||
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
|
||||
() => import("./sections").then((mod) => mod.demoSections),
|
||||
() => import("./arsaboo").then((mod) => mod.demoArsaboo),
|
||||
() => import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
|
||||
() => import("./kernehed").then((mod) => mod.demoKernehed),
|
||||
() => import("./jimpower").then((mod) => mod.demoJimpower),
|
||||
];
|
||||
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
|
||||
sections: () => import("./sections").then((mod) => mod.demoSections),
|
||||
home: () => import("./home").then((mod) => mod.demoHome),
|
||||
arsaboo: () => import("./arsaboo").then((mod) => mod.demoArsaboo),
|
||||
teachingbirds: () =>
|
||||
import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
|
||||
kernehed: () => import("./kernehed").then((mod) => mod.demoKernehed),
|
||||
jimpower: () => import("./jimpower").then((mod) => mod.demoJimpower),
|
||||
};
|
||||
|
||||
export const demos = Object.keys(demoConfigs);
|
||||
|
||||
const initialDemo = () => {
|
||||
const slug = new URLSearchParams(window.location.search).get("demo");
|
||||
return slug && demos.includes(slug) ? slug : demos[0];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemoConfigIndex = 0;
|
||||
export let selectedDemo = initialDemo();
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemoConfig: Promise<DemoConfig> =
|
||||
demoConfigs[selectedDemoConfigIndex]();
|
||||
demoConfigs[selectedDemo]();
|
||||
|
||||
export const setDemoConfig = async (
|
||||
hass: MockHomeAssistant,
|
||||
lovelace: Lovelace,
|
||||
index: number
|
||||
demo: string
|
||||
) => {
|
||||
const confProm = demoConfigs[index]();
|
||||
const confProm = demoConfigs[demo]();
|
||||
const config = await confProm;
|
||||
|
||||
selectedDemoConfigIndex = index;
|
||||
selectedDemo = demo;
|
||||
selectedDemoConfig = confProm;
|
||||
|
||||
setDemoFloors(hass, config.floors);
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
|
||||
// Let the new registries and entities reach the dashboard before saving the
|
||||
// config, so dashboard strategies generate against them
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
await lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
// The view of the previous demo might not exist in the new one
|
||||
navigate(`/${hass.panelUrl}?demo=${demo}`, { replace: true });
|
||||
applyDemoTheme(hass, config.theme);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { DemoArea } from "../../stubs/area_registry";
|
||||
import type { DemoFloor } from "../../stubs/floor_registry";
|
||||
|
||||
export const demoFloorsHome: DemoFloor[] = [
|
||||
{
|
||||
floor_id: "ground",
|
||||
name: "Ground floor",
|
||||
level: 0,
|
||||
},
|
||||
{
|
||||
floor_id: "upstairs",
|
||||
name: "Upstairs",
|
||||
level: 1,
|
||||
},
|
||||
];
|
||||
|
||||
export const demoAreasHome: DemoArea[] = [
|
||||
{
|
||||
area_id: "living_room",
|
||||
name: "Living room",
|
||||
floor_id: "ground",
|
||||
icon: "mdi:sofa",
|
||||
temperature_entity_id: "sensor.living_room_temperature",
|
||||
humidity_entity_id: "sensor.living_room_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "kitchen",
|
||||
name: "Kitchen",
|
||||
floor_id: "ground",
|
||||
icon: "mdi:fridge",
|
||||
temperature_entity_id: "sensor.kitchen_temperature",
|
||||
humidity_entity_id: "sensor.kitchen_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "entrance",
|
||||
name: "Entrance",
|
||||
floor_id: "ground",
|
||||
icon: "mdi:door",
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
name: "Bedroom",
|
||||
floor_id: "upstairs",
|
||||
icon: "mdi:bed",
|
||||
temperature_entity_id: "sensor.bedroom_temperature",
|
||||
humidity_entity_id: "sensor.bedroom_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "office",
|
||||
name: "Office",
|
||||
floor_id: "upstairs",
|
||||
icon: "mdi:desk",
|
||||
temperature_entity_id: "sensor.office_temperature",
|
||||
humidity_entity_id: "sensor.office_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "garden",
|
||||
name: "Garden",
|
||||
icon: "mdi:tree",
|
||||
temperature_entity_id: "sensor.garden_temperature",
|
||||
humidity_entity_id: "sensor.garden_humidity",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,465 @@
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoEntitiesHome: DemoConfig["entities"] = () => [
|
||||
// The devices tile on the overview uses zone.home, which always exists in
|
||||
// a real installation.
|
||||
{
|
||||
entity_id: "zone.home",
|
||||
state: "1",
|
||||
attributes: {
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
friendly_name: "Home",
|
||||
icon: "mdi:home",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.living_room_floor_lamp",
|
||||
state: "on",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
min_color_temp_kelvin: 2000,
|
||||
max_color_temp_kelvin: 6535,
|
||||
supported_color_modes: ["color_temp", "xy"],
|
||||
color_mode: "color_temp",
|
||||
brightness: 178,
|
||||
color_temp_kelvin: 2583,
|
||||
icon: "mdi:floor-lamp",
|
||||
friendly_name: "Floor lamp",
|
||||
supported_features: 44,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.living_room_spotlights",
|
||||
state: "on",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 126,
|
||||
icon: "mdi:ceiling-light-multiple",
|
||||
friendly_name: "Spotlights",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "climate.living_room",
|
||||
state: "heat",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
hvac_modes: ["auto", "heat", "off"],
|
||||
min_temp: 7,
|
||||
max_temp: 35,
|
||||
current_temperature: 21.5,
|
||||
temperature: 21,
|
||||
friendly_name: "Thermostat",
|
||||
supported_features: 385,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "cover.living_room_blinds",
|
||||
state: "open",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
current_position: 100,
|
||||
device_class: "blind",
|
||||
friendly_name: "Blinds",
|
||||
supported_features: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.living_room_window",
|
||||
state: "off",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
device_class: "window",
|
||||
friendly_name: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.living_room_speaker",
|
||||
state: "playing",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
device_class: "speaker",
|
||||
volume_level: 0.28,
|
||||
is_volume_muted: false,
|
||||
media_content_type: "music",
|
||||
media_duration: 300,
|
||||
media_position: 0,
|
||||
media_position_updated_at: new Date(
|
||||
new Date().getTime() - 23000
|
||||
).toISOString(),
|
||||
media_title: "I Wasn't Born To Follow",
|
||||
media_artist: "The Byrds",
|
||||
media_album_name: "The Notorious Byrd Brothers",
|
||||
shuffle: false,
|
||||
friendly_name: "Living room speaker",
|
||||
supported_features: 64063,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.living_room_temperature",
|
||||
state: "21.5",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.living_room_humidity",
|
||||
state: "45",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.kitchen_spotlights",
|
||||
state: "on",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 255,
|
||||
icon: "mdi:ceiling-light-multiple",
|
||||
friendly_name: "Spotlights",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.kitchen_worktop",
|
||||
state: "off",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
icon: "mdi:light-flood-down",
|
||||
friendly_name: "Worktop",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "switch.coffee_machine",
|
||||
state: "on",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
icon: "mdi:coffee-maker",
|
||||
friendly_name: "Coffee machine",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.kitchen_motion",
|
||||
state: "off",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
device_class: "motion",
|
||||
friendly_name: "Motion",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.kitchen_speaker",
|
||||
state: "idle",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
device_class: "speaker",
|
||||
volume_level: 0.18,
|
||||
is_volume_muted: false,
|
||||
friendly_name: "Kitchen speaker",
|
||||
supported_features: 64063,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.kitchen_temperature",
|
||||
state: "22.1",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.kitchen_humidity",
|
||||
state: "48",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lock.front_door",
|
||||
state: "locked",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
friendly_name: "Front door",
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.front_door",
|
||||
state: "off",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
device_class: "door",
|
||||
friendly_name: "Front door",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "alarm_control_panel.home_alarm",
|
||||
state: "disarmed",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
changed_by: null,
|
||||
code_arm_required: false,
|
||||
friendly_name: "Home alarm",
|
||||
supported_features: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.entrance_ceiling",
|
||||
state: "off",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
friendly_name: "Ceiling light",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.front_door_lock_battery",
|
||||
state: "88",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
friendly_name: "Front door lock battery",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.bedroom_ceiling",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
supported_color_modes: ["color_temp"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
friendly_name: "Ceiling light",
|
||||
supported_features: 44,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.bedside_lamp",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
icon: "mdi:lamp",
|
||||
friendly_name: "Bedside lamp",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "cover.bedroom_shutter",
|
||||
state: "open",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
current_position: 100,
|
||||
device_class: "shutter",
|
||||
friendly_name: "Shutter",
|
||||
supported_features: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.bedroom_speaker",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
device_class: "speaker",
|
||||
friendly_name: "Bedroom speaker",
|
||||
supported_features: 64063,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.bedroom_motion",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
device_class: "motion",
|
||||
friendly_name: "Motion",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.bedroom_motion_battery",
|
||||
state: "15",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
friendly_name: "Motion sensor battery",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.bedroom_temperature",
|
||||
state: "19.6",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.bedroom_humidity",
|
||||
state: "52",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.office_desk_lamp",
|
||||
state: "on",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 200,
|
||||
icon: "mdi:desk-lamp",
|
||||
friendly_name: "Desk lamp",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.office_ceiling_fan",
|
||||
state: "off",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
percentage: 0,
|
||||
percentage_step: 25,
|
||||
friendly_name: "Ceiling fan",
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.office_window",
|
||||
state: "off",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
device_class: "window",
|
||||
friendly_name: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.office_temperature",
|
||||
state: "21.8",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.office_humidity",
|
||||
state: "50",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "switch.garden_sprinkler",
|
||||
state: "off",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
icon: "mdi:sprinkler-variant",
|
||||
friendly_name: "Sprinkler",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.garden_path",
|
||||
state: "off",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
icon: "mdi:outdoor-lamp",
|
||||
friendly_name: "Path lights",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.garden_gate",
|
||||
state: "off",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
device_class: "opening",
|
||||
friendly_name: "Gate",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.garden_temperature",
|
||||
state: "14.2",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.garden_humidity",
|
||||
state: "68",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "weather.home",
|
||||
state: "partlycloudy",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
temperature: 14.2,
|
||||
temperature_unit: "°C",
|
||||
humidity: 68,
|
||||
pressure: 1012,
|
||||
pressure_unit: "hPa",
|
||||
wind_speed: 11.2,
|
||||
wind_speed_unit: "km/h",
|
||||
friendly_name: "Home",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { DemoConfig } from "../types";
|
||||
import { demoAreasHome, demoFloorsHome } from "./areas";
|
||||
import { demoEntitiesHome } from "./entities";
|
||||
import { demoLovelaceHome } from "./lovelace";
|
||||
|
||||
export const demoHome: DemoConfig = {
|
||||
authorName: "Home Assistant",
|
||||
authorUrl: "https://www.home-assistant.io",
|
||||
name: "Home page",
|
||||
description:
|
||||
"The page you land on when you open Home Assistant, automatically built from the areas in your home and the devices in them.",
|
||||
lovelace: demoLovelaceHome,
|
||||
entities: demoEntitiesHome,
|
||||
floors: demoFloorsHome,
|
||||
areas: demoAreasHome,
|
||||
theme: { theme: "default", dark: false },
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import "./strategies";
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoLovelaceHome: DemoConfig["lovelace"] = () => ({
|
||||
strategy: {
|
||||
type: "custom:demo-home",
|
||||
favorite_entities: ["lock.front_door", "switch.garden_sprinkler"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { isStrategySection } from "../../../../src/data/lovelace/config/section";
|
||||
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view";
|
||||
import { isStrategyView } from "../../../../src/data/lovelace/config/view";
|
||||
import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
|
||||
import { HomeDashboardStrategy } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
|
||||
import type { HomeOverviewViewStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { HomeOverviewViewStrategy } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { generateLovelaceSectionStrategy } from "../../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
export interface DemoHomeDashboardStrategyConfig extends Omit<
|
||||
HomeDashboardStrategyConfig,
|
||||
"type"
|
||||
> {
|
||||
type: "custom:demo-home";
|
||||
}
|
||||
|
||||
interface DemoHomeOverviewViewStrategyConfig extends Omit<
|
||||
HomeOverviewViewStrategyConfig,
|
||||
"type"
|
||||
> {
|
||||
type: "custom:demo-home-overview";
|
||||
}
|
||||
|
||||
class DemoHomeDashboardStrategy extends ReactiveElement {
|
||||
static registryDependencies = HomeDashboardStrategy.registryDependencies;
|
||||
|
||||
static async generate(
|
||||
config: DemoHomeDashboardStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceConfig> {
|
||||
const generated = await HomeDashboardStrategy.generate(
|
||||
{ ...config, type: "home" },
|
||||
hass
|
||||
);
|
||||
// Swap the overview view for the demo version, which adds the demo card.
|
||||
return {
|
||||
...generated,
|
||||
views: generated.views.map((view) =>
|
||||
isStrategyView(view) && view.strategy.type === "home-overview"
|
||||
? {
|
||||
...view,
|
||||
strategy: { ...view.strategy, type: "custom:demo-home-overview" },
|
||||
}
|
||||
: view
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DemoHomeOverviewViewStrategy extends ReactiveElement {
|
||||
static registryDependencies = HomeOverviewViewStrategy.registryDependencies;
|
||||
|
||||
static async generate(
|
||||
config: DemoHomeOverviewViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{ ...config, type: "home-overview" },
|
||||
hass
|
||||
);
|
||||
// Expand the favorites section so the demo card can be added to it
|
||||
const sections = await Promise.all(
|
||||
(view.sections || []).map(async (section) => {
|
||||
if (
|
||||
!isStrategySection(section) ||
|
||||
section.strategy.type !== "common-controls"
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
// The demo card takes up the space of two tiles
|
||||
const limit = (section.strategy.limit as number | undefined) ?? 8;
|
||||
const favorites = await generateLovelaceSectionStrategy(
|
||||
{ ...section, strategy: { ...section.strategy, limit: limit - 2 } },
|
||||
hass
|
||||
);
|
||||
const [heading, ...cards] = favorites.cards || [];
|
||||
return {
|
||||
...favorites,
|
||||
// Place the demo card first so the tiles fill the rows next to it
|
||||
cards: [heading, { type: "custom:ha-demo-next-card" }, ...cards],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...view,
|
||||
sections: sections,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
"ll-strategy-dashboard-demo-home",
|
||||
DemoHomeDashboardStrategy
|
||||
);
|
||||
customElements.define(
|
||||
"ll-strategy-view-demo-home-overview",
|
||||
DemoHomeOverviewViewStrategy
|
||||
);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ll-strategy-dashboard-demo-home": DemoHomeDashboardStrategy;
|
||||
"ll-strategy-view-demo-home-overview": DemoHomeOverviewViewStrategy;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
import type { DemoArea } from "../stubs/area_registry";
|
||||
import type { DemoFloor } from "../stubs/floor_registry";
|
||||
|
||||
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
|
||||
|
||||
@@ -13,7 +15,9 @@ export interface DemoConfig {
|
||||
authorUrl: string;
|
||||
description?:
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
floors?: DemoFloor[];
|
||||
areas?: DemoArea[];
|
||||
theme: DemoTheme;
|
||||
}
|
||||
|
||||
@@ -41,25 +41,30 @@ class CastDemoRow extends LitElement implements LovelaceRow {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
import("../../../src/cast/cast_manager").then(({ getCastManager }) =>
|
||||
getCastManager().then((mgr) => {
|
||||
this._castManager = mgr;
|
||||
mgr.addEventListener("state-changed", () => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
mgr.castContext.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
(ev) => {
|
||||
// On Android, opening a new session always results in SESSION_RESUMED.
|
||||
// So treat both as the same.
|
||||
if (
|
||||
ev.sessionState === "SESSION_STARTED" ||
|
||||
ev.sessionState === "SESSION_RESUMED"
|
||||
) {
|
||||
castSendShowDemo(mgr);
|
||||
getCastManager().then(
|
||||
(mgr) => {
|
||||
this._castManager = mgr;
|
||||
mgr.addEventListener("state-changed", () => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
mgr.castContext.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
(ev) => {
|
||||
// On Android, opening a new session always results in SESSION_RESUMED.
|
||||
// So treat both as the same.
|
||||
if (
|
||||
ev.sessionState === "SESSION_STARTED" ||
|
||||
ev.sessionState === "SESSION_RESUMED"
|
||||
) {
|
||||
castSendShowDemo(mgr);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
},
|
||||
() => {
|
||||
this._castManager = null;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import type {
|
||||
LovelaceCard,
|
||||
} from "../../../src/panels/lovelace/types";
|
||||
import {
|
||||
demoConfigs,
|
||||
demos,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
selectedDemoConfigIndex,
|
||||
} from "../configs/demo-configs";
|
||||
|
||||
@customElement("ha-demo-card")
|
||||
@@ -112,16 +112,12 @@ export class HADemoCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
private _nextConfig() {
|
||||
this._updateConfig(
|
||||
selectedDemoConfigIndex < demoConfigs.length - 1
|
||||
? selectedDemoConfigIndex + 1
|
||||
: 0
|
||||
);
|
||||
this._updateConfig(demos[(demos.indexOf(selectedDemo) + 1) % demos.length]);
|
||||
}
|
||||
|
||||
private async _updateConfig(index: number) {
|
||||
private async _updateConfig(demo: string) {
|
||||
this._switching = true;
|
||||
fireEvent(this, "set-demo-config" as any, { index });
|
||||
fireEvent(this, "set-demo-config" as any, { demo });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-control-button";
|
||||
import "../../../src/components/ha-control-button-group";
|
||||
import "../../../src/components/tile/ha-tile-container";
|
||||
import "../../../src/components/tile/ha-tile-icon";
|
||||
import "../../../src/components/tile/ha-tile-info";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { tileCardStyle } from "../../../src/panels/lovelace/cards/tile/tile-card-style";
|
||||
import type {
|
||||
LovelaceCard,
|
||||
LovelaceGridOptions,
|
||||
} from "../../../src/panels/lovelace/types";
|
||||
import {
|
||||
demos,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
} from "../configs/demo-configs";
|
||||
|
||||
@customElement("ha-demo-next-card")
|
||||
export class HADemoNextCard extends LitElement implements LovelaceCard {
|
||||
@property({ attribute: false }) public hass!: MockHomeAssistant;
|
||||
|
||||
@state() private _switching = false;
|
||||
|
||||
public getCardSize() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
return {
|
||||
columns: 6,
|
||||
rows: 2,
|
||||
min_columns: 6,
|
||||
min_rows: 2,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public setConfig(_config: LovelaceCardConfig) {}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-card>
|
||||
<ha-tile-container>
|
||||
<ha-tile-icon slot="icon" icon="mdi:home-assistant"></ha-tile-icon>
|
||||
<ha-tile-info slot="info">
|
||||
<span slot="primary">
|
||||
${until(
|
||||
selectedDemoConfig.then((conf) => conf.name),
|
||||
nothing
|
||||
)}
|
||||
</span>
|
||||
<span slot="secondary">
|
||||
${this.hass.localize(
|
||||
"ui.panel.page-demo.cards.demo.interactive_demo"
|
||||
)}
|
||||
</span>
|
||||
</ha-tile-info>
|
||||
<ha-control-button-group slot="features">
|
||||
<ha-control-button
|
||||
.disabled=${this._switching}
|
||||
@click=${this._nextConfig}
|
||||
>
|
||||
${this.hass.localize("ui.panel.page-demo.cards.demo.next_demo")}
|
||||
</ha-control-button>
|
||||
</ha-control-button-group>
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _nextConfig() {
|
||||
this._switching = true;
|
||||
fireEvent(this, "set-demo-config" as any, {
|
||||
demo: demos[(demos.indexOf(selectedDemo) + 1) % demos.length],
|
||||
});
|
||||
}
|
||||
|
||||
static styles = [
|
||||
tileCardStyle,
|
||||
css`
|
||||
:host {
|
||||
--tile-color: var(--primary-color);
|
||||
}
|
||||
ha-control-button-group {
|
||||
--control-button-group-spacing: 0;
|
||||
--control-button-group-thickness: var(--feature-height, 42px);
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-demo-next-card": HADemoNextCard;
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -6,7 +6,7 @@ import { provideHass } from "../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
@@ -14,7 +14,7 @@ import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry } from "./stubs/floor_registry";
|
||||
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
@@ -29,6 +29,7 @@ import { mockSystemLog } from "./stubs/system_log";
|
||||
import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import { mockUsagePrediction } from "./stubs/usage_prediction";
|
||||
import "./cloud/cloud-demo-controls";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
@@ -74,11 +75,17 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
// The cloud account page only fetches backup config and the webhook count
|
||||
// when those integrations are loaded. Enable them here (demo only) so the
|
||||
// mocked backup/config/info and webhook/list are queried.
|
||||
// mocked backup/config/info and webhook/list are queried. usage_prediction
|
||||
// is needed for common-controls sections in strategy dashboards.
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...(hass.config?.components ?? []), "backup", "webhook"],
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -122,6 +129,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
@@ -169,9 +177,11 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
|
||||
// Once config is loaded AND localize, set entities and apply theme.
|
||||
// Once config is loaded AND localize, set registries, entities and theme.
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
([conf, localize]) => {
|
||||
setDemoFloors(hass, conf.floors);
|
||||
setDemoAreas(hass, conf.areas);
|
||||
hass.addEntities(conf.entities(localize));
|
||||
applyDemoTheme(hass, conf.theme);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export interface DemoArea {
|
||||
area_id: string;
|
||||
name: string;
|
||||
floor_id?: string;
|
||||
icon?: string;
|
||||
temperature_entity_id?: string;
|
||||
humidity_entity_id?: string;
|
||||
}
|
||||
|
||||
let areas: AreaRegistryEntry[] = [];
|
||||
|
||||
export const mockAreaRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: AreaRegistryEntry[] = []
|
||||
data: DemoArea[] = []
|
||||
) => {
|
||||
hass.mockWS("config/area_registry/list", () => data);
|
||||
const areas = {};
|
||||
data.forEach((area) => {
|
||||
areas[area.area_id] = area;
|
||||
});
|
||||
hass.updateHass({ areas });
|
||||
hass.mockWS("config/area_registry/list", () => areas);
|
||||
setDemoAreas(hass, data);
|
||||
};
|
||||
|
||||
/** Set the areas of the currently loaded demo config. */
|
||||
export const setDemoAreas = (
|
||||
hass: MockHomeAssistant,
|
||||
demoAreas: DemoArea[] = []
|
||||
) => {
|
||||
areas = demoAreas.map((area) => ({
|
||||
area_id: area.area_id,
|
||||
name: area.name,
|
||||
floor_id: area.floor_id ?? null,
|
||||
icon: area.icon ?? null,
|
||||
temperature_entity_id: area.temperature_entity_id ?? null,
|
||||
humidity_entity_id: area.humidity_entity_id ?? null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
picture: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
const areasById: Record<string, AreaRegistryEntry> = {};
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = area;
|
||||
});
|
||||
hass.updateHass({ areas: areasById });
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ export const energyEntities = () =>
|
||||
},
|
||||
},
|
||||
"sensor.energy_consumption_tarif_1": {
|
||||
entity_id: "sensor.energy_consumption_tarif_1 ",
|
||||
entity_id: "sensor.energy_consumption_tarif_1",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import type { FloorRegistryEntry } from "../../../src/data/floor_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export interface DemoFloor {
|
||||
floor_id: string;
|
||||
name: string;
|
||||
level?: number;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
let floors: FloorRegistryEntry[] = [];
|
||||
|
||||
export const mockFloorRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: FloorRegistryEntry[] = []
|
||||
) => hass.mockWS("config/floor_registry/list", () => data);
|
||||
data: DemoFloor[] = []
|
||||
) => {
|
||||
hass.mockWS("config/floor_registry/list", () => floors);
|
||||
setDemoFloors(hass, data);
|
||||
};
|
||||
|
||||
/** Set the floors of the currently loaded demo config. */
|
||||
export const setDemoFloors = (
|
||||
hass: MockHomeAssistant,
|
||||
demoFloors: DemoFloor[] = []
|
||||
) => {
|
||||
floors = demoFloors.map((floor) => ({
|
||||
floor_id: floor.floor_id,
|
||||
name: floor.name,
|
||||
level: floor.level ?? null,
|
||||
icon: floor.icon ?? null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
const floorsById: Record<string, FloorRegistryEntry> = {};
|
||||
floors.forEach((floor) => {
|
||||
floorsById[floor.floor_id] = floor;
|
||||
});
|
||||
hass.updateHass({ floors: floorsById });
|
||||
};
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
selectedDemoConfigIndex,
|
||||
setDemoConfig,
|
||||
} from "../configs/demo-configs";
|
||||
import "../custom-cards/cast-demo-row";
|
||||
import "../custom-cards/ha-demo-card";
|
||||
import "../custom-cards/ha-demo-next-card";
|
||||
import { mapEntities } from "./entities";
|
||||
|
||||
export const mockLovelace = (
|
||||
@@ -45,11 +46,11 @@ customElements.whenDefined("hui-root").then(() => {
|
||||
HUIRoot.prototype.firstUpdated = function (changedProperties) {
|
||||
oldFirstUpdated.call(this, changedProperties);
|
||||
this.addEventListener("set-demo-config", async (ev) => {
|
||||
const index = (ev as CustomEvent).detail.index;
|
||||
const demo = (ev as CustomEvent).detail.demo;
|
||||
try {
|
||||
await setDemoConfig(this.hass, this.lovelace!, index);
|
||||
await setDemoConfig(this.hass, this.lovelace!, demo);
|
||||
} catch (_err: any) {
|
||||
setDemoConfig(this.hass, this.lovelace!, selectedDemoConfigIndex);
|
||||
setDemoConfig(this.hass, this.lovelace!, selectedDemo);
|
||||
alert("Failed to switch config :-(");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CommonControlsResult } from "../../../src/data/usage_prediction";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUsagePrediction = (hass: MockHomeAssistant) => {
|
||||
// Entities that don't exist in the currently loaded demo config are
|
||||
// filtered out by the common-controls section strategy.
|
||||
hass.mockWS("usage_prediction/common_control", (): CommonControlsResult => ({
|
||||
entities: [
|
||||
"light.living_room_floor_lamp",
|
||||
"climate.living_room",
|
||||
"cover.living_room_blinds",
|
||||
"media_player.living_room_speaker",
|
||||
"light.kitchen_spotlights",
|
||||
"switch.coffee_machine",
|
||||
"light.bedside_lamp",
|
||||
"alarm_control_panel.home_alarm",
|
||||
],
|
||||
}));
|
||||
};
|
||||
@@ -2,7 +2,10 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import {
|
||||
mockAreaRegistry,
|
||||
type DemoArea,
|
||||
} from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
@@ -10,7 +13,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
|
||||
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
|
||||
import "../../../../src/components/ha-form/ha-form";
|
||||
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
@@ -136,45 +138,20 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
const AREAS: DemoArea[] = [
|
||||
{
|
||||
area_id: "backyard",
|
||||
floor_id: null,
|
||||
name: "Backyard",
|
||||
icon: null,
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
floor_id: null,
|
||||
name: "Bedroom",
|
||||
icon: "mdi:bed",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
floor_id: null,
|
||||
name: "Livingroom",
|
||||
icon: "mdi:sofa",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import {
|
||||
mockAreaRegistry,
|
||||
type DemoArea,
|
||||
} from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
|
||||
import {
|
||||
mockFloorRegistry,
|
||||
type DemoFloor,
|
||||
} from "../../../../demo/src/stubs/floor_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
import {
|
||||
showDialog,
|
||||
@@ -147,75 +151,43 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
const AREAS: DemoArea[] = [
|
||||
{
|
||||
area_id: "backyard",
|
||||
floor_id: "ground",
|
||||
name: "Backyard",
|
||||
icon: null,
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
floor_id: "first",
|
||||
name: "Bedroom",
|
||||
icon: "mdi:bed",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
floor_id: "ground",
|
||||
name: "Livingroom",
|
||||
icon: "mdi:sofa",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const FLOORS: FloorRegistryEntry[] = [
|
||||
const FLOORS: DemoFloor[] = [
|
||||
{
|
||||
floor_id: "ground",
|
||||
name: "Ground floor",
|
||||
level: 0,
|
||||
icon: null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
floor_id: "first",
|
||||
name: "First floor",
|
||||
level: 1,
|
||||
icon: "mdi:numeric-1",
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
floor_id: "second",
|
||||
name: "Second floor",
|
||||
level: 2,
|
||||
icon: "mdi:numeric-2",
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+11
-12
@@ -91,7 +91,7 @@
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.1",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.0",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.49.0",
|
||||
"cropperjs": "1.6.2",
|
||||
@@ -108,14 +108,14 @@
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.12",
|
||||
"js-yaml": "5.2.1",
|
||||
"js-yaml": "5.2.2",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.6",
|
||||
"marked": "18.0.7",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -151,8 +151,8 @@
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.0",
|
||||
"@rspack/core": "2.1.4",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.5",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -196,22 +196,22 @@
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.1.0",
|
||||
"lint-staged": "17.2.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.5",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.20",
|
||||
"sinon": "22.1.0",
|
||||
"tar": "7.5.21",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.64.0",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -226,8 +226,7 @@
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.7.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",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
"@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": "yarn@4.17.1",
|
||||
"volta": {
|
||||
|
||||
+11
-10
@@ -9,16 +9,17 @@ export const castApiAvailable = () => {
|
||||
|
||||
loadedPromise = new Promise((resolve) => {
|
||||
(window as any).__onGCastApiAvailable = resolve;
|
||||
});
|
||||
// Any element with a specific ID will get set as a JS variable on window
|
||||
// This will override the cast SDK if the iconset is loaded afterwards.
|
||||
// Conflicting IDs will no longer mess with window, so we'll just append one.
|
||||
const el = document.createElement("div");
|
||||
el.id = "cast";
|
||||
document.body.append(el);
|
||||
|
||||
loadJS(
|
||||
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||
);
|
||||
// Any element with a specific ID will get set as a JS variable on window
|
||||
// This will override the cast SDK if the iconset is loaded afterwards.
|
||||
// Conflicting IDs will no longer mess with window, so we'll just append one.
|
||||
const el = document.createElement("div");
|
||||
el.id = "cast";
|
||||
document.body.append(el);
|
||||
|
||||
loadJS(
|
||||
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||
).catch(() => resolve(false));
|
||||
});
|
||||
return loadedPromise;
|
||||
};
|
||||
|
||||
+11
-18
@@ -64,22 +64,15 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (path.includes("#")) {
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
return true;
|
||||
if (!path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
@@ -87,10 +80,10 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
`${mainWindow.location.pathname}#${path}`
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.location.hash = path;
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
|
||||
@@ -4,6 +4,23 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import QRCode from "qrcode";
|
||||
import "./ha-alert";
|
||||
import { rgb2hex } from "../common/color/convert-color";
|
||||
import {
|
||||
getContrastedColorHex,
|
||||
getRGBContrastRatio,
|
||||
} from "../common/color/rgb";
|
||||
|
||||
// Minimum contrast ratio (WCAG non-text minimum) below which we substitute a
|
||||
// legible foreground so the QR code never renders unscannable.
|
||||
const MIN_CONTRAST_RATIO = 3;
|
||||
|
||||
const parseRgbVariable = (
|
||||
value: string
|
||||
): [number, number, number] | undefined => {
|
||||
const parts = value.split(",").map((part) => parseInt(part, 10));
|
||||
return parts.length === 3 && parts.every((part) => !Number.isNaN(part))
|
||||
? (parts as [number, number, number])
|
||||
: undefined;
|
||||
};
|
||||
|
||||
@customElement("ha-qr-code")
|
||||
export class HaQrCode extends LitElement {
|
||||
@@ -60,27 +77,27 @@ export class HaQrCode extends LitElement {
|
||||
changedProperties.has("centerImage"))
|
||||
) {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const textRgb = computedStyles.getPropertyValue(
|
||||
"--rgb-primary-text-color"
|
||||
const textRgb = parseRgbVariable(
|
||||
computedStyles.getPropertyValue("--rgb-primary-text-color")
|
||||
);
|
||||
const backgroundRgb = computedStyles.getPropertyValue(
|
||||
"--rgb-card-background-color"
|
||||
);
|
||||
const textHex = rgb2hex(
|
||||
textRgb.split(",").map((a) => parseInt(a, 10)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]
|
||||
);
|
||||
const backgroundHex = rgb2hex(
|
||||
backgroundRgb.split(",").map((a) => parseInt(a, 10)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]
|
||||
const backgroundRgb = parseRgbVariable(
|
||||
computedStyles.getPropertyValue("--rgb-card-background-color")
|
||||
);
|
||||
|
||||
const backgroundHex = backgroundRgb ? rgb2hex(backgroundRgb) : "#ffffff";
|
||||
let textHex = textRgb ? rgb2hex(textRgb) : "#000000";
|
||||
|
||||
// If the theme colors are missing/invalid or don't contrast enough, the
|
||||
// QR code would be unscannable (e.g. white-on-white). Fall back to a
|
||||
// foreground guaranteed to contrast the resolved background.
|
||||
if (
|
||||
!textRgb ||
|
||||
!backgroundRgb ||
|
||||
getRGBContrastRatio(textRgb, backgroundRgb) < MIN_CONTRAST_RATIO
|
||||
) {
|
||||
textHex = getContrastedColorHex(backgroundHex);
|
||||
}
|
||||
|
||||
QRCode.toCanvas(canvas, this.data, {
|
||||
errorCorrectionLevel:
|
||||
this.errorCorrectionLevel || (this.centerImage ? "Q" : "M"),
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ToastClosedEventDetail {
|
||||
export class HaToast extends LitElement {
|
||||
@property({ attribute: "label-text" }) public labelText = "";
|
||||
|
||||
@property({ attribute: "announce-text" }) public announceText?: string;
|
||||
|
||||
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
|
||||
|
||||
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
|
||||
@@ -190,8 +192,6 @@ export class HaToast extends LitElement {
|
||||
style=${styleMap({
|
||||
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
|
||||
})}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
|
||||
>
|
||||
<span class="message">${this.labelText}</span>
|
||||
@@ -200,6 +200,14 @@ export class HaToast extends LitElement {
|
||||
<slot name="dismiss"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="assistive-message"
|
||||
role="status"
|
||||
aria-live=${this._active ? "polite" : "off"}
|
||||
aria-atomic="true"
|
||||
>
|
||||
${this.announceText ?? this.labelText}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -254,6 +262,18 @@ export class HaToast extends LitElement {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.assistive-message {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -36,3 +36,13 @@ export interface DirtyStateContext<
|
||||
* boundary.
|
||||
*/
|
||||
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
isDirtyState?: boolean;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"dirty-state-changed": { isDirty: boolean };
|
||||
}
|
||||
}
|
||||
|
||||
+32
-3
@@ -15,10 +15,26 @@ export interface HttpConfig {
|
||||
ssl_profile?: "modern" | "intermediate";
|
||||
}
|
||||
|
||||
// The slot the running HTTP server was actually started with.
|
||||
export type ActiveConfigType = "stable" | "pending" | "default";
|
||||
|
||||
// A stored config slot carries metadata alongside the editable fields:
|
||||
// - created_at: when the slot was staged
|
||||
// - error: null while healthy; set once a slot could not be applied or a
|
||||
// pending trial was not confirmed (then it is kept for display, not retried)
|
||||
export interface HttpConfigWithMeta extends HttpConfig {
|
||||
created_at?: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface HttpConfigState {
|
||||
stable: HttpConfig;
|
||||
pending: HttpConfig | null;
|
||||
stable: HttpConfigWithMeta;
|
||||
pending: HttpConfigWithMeta | null;
|
||||
revert_at: string | null;
|
||||
// Added in the "active HTTP config slot" backend change; optional so the
|
||||
// frontend keeps working against cores without it.
|
||||
active_config_type?: ActiveConfigType;
|
||||
default?: HttpConfigWithMeta;
|
||||
}
|
||||
|
||||
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
|
||||
@@ -40,6 +56,19 @@ export interface SaveHttpConfigResult {
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
// Keep only the editable fields; the backend storage schema rejects unknown
|
||||
// keys, so the created_at/error metadata that rides along on a fetched slot
|
||||
// must be dropped before configuring.
|
||||
export const stripHttpConfigMeta = (config: HttpConfig): HttpConfig => {
|
||||
const stripped: Partial<Record<keyof HttpConfig, unknown>> = {};
|
||||
for (const key of HTTP_CONFIG_FIELDS) {
|
||||
if (config[key] !== undefined) {
|
||||
stripped[key] = config[key];
|
||||
}
|
||||
}
|
||||
return stripped as HttpConfig;
|
||||
};
|
||||
|
||||
export const fetchHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<HttpConfigState>({ type: "http/config" });
|
||||
|
||||
@@ -49,7 +78,7 @@ export const saveHttpConfig = (
|
||||
) =>
|
||||
hass.callWS<SaveHttpConfigResult>({
|
||||
type: "http/config/configure",
|
||||
config,
|
||||
config: config ? stripHttpConfigMeta(config) : null,
|
||||
});
|
||||
|
||||
export const promoteHttpConfig = (hass: HomeAssistant) =>
|
||||
|
||||
@@ -45,69 +45,6 @@ export interface MatterNodeDiagnostics {
|
||||
|
||||
export type MatterPingResult = Record<string, boolean>;
|
||||
|
||||
export type MatterTopologyNodeKind =
|
||||
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
|
||||
|
||||
export type MatterTopologyStrength = "strong" | "medium" | "weak" | "none";
|
||||
|
||||
export interface MatterTopologyDirectionInfo {
|
||||
strength: MatterTopologyStrength;
|
||||
lqi?: number | null;
|
||||
rssi?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyNode {
|
||||
id: string;
|
||||
kind: MatterTopologyNodeKind;
|
||||
network_type: string;
|
||||
node_id?: number | null;
|
||||
ha_device_id?: string | null;
|
||||
role?: string | null;
|
||||
available?: boolean | null;
|
||||
is_bridge?: boolean | null;
|
||||
ext_address?: string | null;
|
||||
rloc16?: number | null;
|
||||
ext_pan_id?: string | null;
|
||||
network_name?: string | null;
|
||||
vendor_name?: string | null;
|
||||
model_name?: string | null;
|
||||
last_seen?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyConnection {
|
||||
source: string;
|
||||
target: string;
|
||||
network: string;
|
||||
strength: MatterTopologyStrength;
|
||||
source_to_target?: MatterTopologyDirectionInfo | null;
|
||||
target_to_source?: MatterTopologyDirectionInfo | null;
|
||||
via_route_table?: boolean | null;
|
||||
path_cost?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopology {
|
||||
collected_at: number;
|
||||
nodes: MatterNetworkTopologyNode[];
|
||||
connections: MatterNetworkTopologyConnection[];
|
||||
}
|
||||
|
||||
export const fetchMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
refresh = false
|
||||
): Promise<MatterNetworkTopology> =>
|
||||
hass.callWS({
|
||||
type: "matter/network_topology",
|
||||
refresh,
|
||||
});
|
||||
|
||||
export const subscribeMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
callback: (topology: MatterNetworkTopology) => void
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
|
||||
type: "matter/subscribe_network_topology",
|
||||
});
|
||||
|
||||
export interface MatterCommissioningParameters {
|
||||
setup_pin_code: number;
|
||||
setup_manual_code: string;
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface NumberDeviceClassUnits {
|
||||
units: string[];
|
||||
}
|
||||
|
||||
const AUTO_MODE_MAX_STEPS = 256;
|
||||
|
||||
export const showNumberSlider = (stateObj: HassEntity): boolean => {
|
||||
const { mode, min, max, step } = stateObj.attributes;
|
||||
if (mode === "slider") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
mode === "auto" &&
|
||||
(Number(max) - Number(min)) / Number(step) <= AUTO_MODE_MAX_STEPS
|
||||
);
|
||||
};
|
||||
|
||||
export const getNumberDeviceClassConvertibleUnits = (
|
||||
hass: HomeAssistant,
|
||||
deviceClass: string
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isTiltOnly } from "../../../data/cover";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
import type { ImageEntity } from "../../../data/image";
|
||||
import { computeImageUrl } from "../../../data/image";
|
||||
import { showNumberSlider } from "../../../data/number";
|
||||
import "../../../panels/lovelace/components/hui-timestamp-display";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
@@ -249,15 +250,9 @@ class EntityPreviewRow extends LitElement {
|
||||
}
|
||||
|
||||
if (domain === "number") {
|
||||
const showNumberSlider =
|
||||
stateObj.attributes.mode === "slider" ||
|
||||
(stateObj.attributes.mode === "auto" &&
|
||||
(Number(stateObj.attributes.max) - Number(stateObj.attributes.min)) /
|
||||
Number(stateObj.attributes.step) <=
|
||||
256);
|
||||
return html`
|
||||
${
|
||||
showNumberSlider
|
||||
showNumberSlider(stateObj)
|
||||
? html`
|
||||
<div class="numberflex">
|
||||
<ha-slider
|
||||
|
||||
@@ -108,7 +108,7 @@ class StepFlowForm extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
step.data_schema.length
|
||||
step.data_schema.length || this._errors
|
||||
? html`<ha-form
|
||||
${ref(this._formRef)}
|
||||
?autofocus=${this.autoFocus}
|
||||
|
||||
@@ -54,6 +54,7 @@ export class HaImagecropperDialog
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (!changedProperties.has("_params") || !this._params) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,18 +78,21 @@ class MoreInfoLawnMower extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private get _startPauseIcon(): string {
|
||||
if (!this.stateObj) return mdiPlay;
|
||||
return isMowing(this.stateObj) &&
|
||||
private get _showPause(): boolean {
|
||||
if (!this.stateObj) return false;
|
||||
return (
|
||||
isMowing(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
|
||||
? mdiPause
|
||||
: mdiPlay;
|
||||
);
|
||||
}
|
||||
|
||||
private get _startPauseIcon(): string {
|
||||
return this._showPause ? mdiPause : mdiPlay;
|
||||
}
|
||||
|
||||
private get _startPauseLabel(): string {
|
||||
if (!this.stateObj) return "";
|
||||
return isMowing(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
|
||||
return this._showPause
|
||||
? this._i18n.localize("ui.dialogs.more_info_control.lawn_mower.pause")
|
||||
: this._i18n.localize(
|
||||
"ui.dialogs.more_info_control.lawn_mower.start_mowing"
|
||||
@@ -99,8 +102,11 @@ class MoreInfoLawnMower extends LitElement {
|
||||
private get _startPauseDisabled(): boolean {
|
||||
if (!this.stateObj) return true;
|
||||
if (this.stateObj.state === UNAVAILABLE) return true;
|
||||
if (isMowing(this.stateObj)) return false;
|
||||
return !canStartMowing(this.stateObj);
|
||||
if (this._showPause) return false;
|
||||
return (
|
||||
!supportsFeature(this.stateObj, LawnMowerEntityFeature.START_MOWING) ||
|
||||
!canStartMowing(this.stateObj)
|
||||
);
|
||||
}
|
||||
|
||||
private _renderBattery() {
|
||||
@@ -156,7 +162,7 @@ class MoreInfoLawnMower extends LitElement {
|
||||
private _handleStartPause() {
|
||||
if (!this.stateObj) return;
|
||||
forwardHaptic(this, "light");
|
||||
if (isMowing(this.stateObj)) {
|
||||
if (this._showPause) {
|
||||
this._api.callService("lawn_mower", "pause", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,38 @@ export const demoPanels: Panels = {
|
||||
config: null,
|
||||
url_path: "home",
|
||||
},
|
||||
light: {
|
||||
component_name: "light",
|
||||
icon: "mdi:lamps",
|
||||
title: "light",
|
||||
default_visible: false,
|
||||
config: null,
|
||||
url_path: "light",
|
||||
},
|
||||
climate: {
|
||||
component_name: "climate",
|
||||
icon: "mdi:home-thermometer",
|
||||
title: "climate",
|
||||
default_visible: false,
|
||||
config: null,
|
||||
url_path: "climate",
|
||||
},
|
||||
security: {
|
||||
component_name: "security",
|
||||
icon: "mdi:security",
|
||||
title: "security",
|
||||
default_visible: false,
|
||||
config: null,
|
||||
url_path: "security",
|
||||
},
|
||||
maintenance: {
|
||||
component_name: "maintenance",
|
||||
icon: "mdi:wrench",
|
||||
title: "maintenance",
|
||||
default_visible: false,
|
||||
config: null,
|
||||
url_path: "maintenance",
|
||||
},
|
||||
"dev-state": {
|
||||
component_name: "dev-state",
|
||||
icon: null,
|
||||
|
||||
@@ -27,6 +27,8 @@ export class MockBaseEntity {
|
||||
|
||||
public state: string;
|
||||
|
||||
public areaId?: string;
|
||||
|
||||
public baseAttributes: EntityAttributes;
|
||||
|
||||
public attributes: EntityAttributes;
|
||||
@@ -47,6 +49,7 @@ export class MockBaseEntity {
|
||||
this.domain = domain;
|
||||
this.objectId = objectId;
|
||||
this.state = input.state;
|
||||
this.areaId = input.area_id;
|
||||
this.lastChanged = randomTime();
|
||||
this.lastUpdated = randomTime();
|
||||
|
||||
@@ -127,6 +130,8 @@ export class MockBaseEntity {
|
||||
"entity_picture",
|
||||
"assumed_state",
|
||||
"device_class",
|
||||
"state_class",
|
||||
"unit_of_measurement",
|
||||
"supported_features",
|
||||
]) {
|
||||
if (key in attrs) {
|
||||
|
||||
@@ -10,7 +10,10 @@ export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
|
||||
export type EntityInput = Pick<
|
||||
HassEntity,
|
||||
"entity_id" | "state" | "attributes"
|
||||
>;
|
||||
> & {
|
||||
/** Area the entity is assigned to in the mocked entity registry */
|
||||
area_id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The hass mock object interface, kept intentionally loose
|
||||
|
||||
@@ -299,6 +299,7 @@ export const provideHass = (
|
||||
icon: undefined,
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
area_id: ent.areaId,
|
||||
} satisfies EntityRegistryDisplayEntry;
|
||||
});
|
||||
if (replace) {
|
||||
|
||||
@@ -173,11 +173,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
window.addEventListener("location-changed", () => updateRoute());
|
||||
|
||||
// Handle history changes
|
||||
if (useHash) {
|
||||
window.addEventListener("hashchange", () => updateRoute());
|
||||
} else {
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
}
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
|
||||
// Handle clicking on links
|
||||
window.addEventListener("click", (ev) => {
|
||||
@@ -269,7 +265,14 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// The check re-runs on the next reconnect; ignore transient failures.
|
||||
return;
|
||||
}
|
||||
if (!httpConfig.pending || this._httpPendingDialogOpen) {
|
||||
// Only prompt for an active trial. A pending config with an error was
|
||||
// already reverted/failed and is kept only for display in the config form,
|
||||
// so it must not pop the confirm/revert dialog.
|
||||
if (
|
||||
!httpConfig.pending ||
|
||||
httpConfig.pending.error ||
|
||||
this._httpPendingDialogOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._httpPendingDialogOpen = true;
|
||||
|
||||
@@ -13,8 +13,13 @@ export interface ShowToastParams {
|
||||
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
|
||||
id?: string;
|
||||
message:
|
||||
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
|
||||
| string
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
announceMessage?:
|
||||
| string
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
action?: ToastActionParams;
|
||||
secondaryAction?: ToastActionParams;
|
||||
dismiss?: () => void;
|
||||
duration?: number;
|
||||
dismissable?: boolean;
|
||||
@@ -23,8 +28,10 @@ export interface ShowToastParams {
|
||||
|
||||
export interface ToastActionParams {
|
||||
action: () => void;
|
||||
primary?: boolean;
|
||||
text:
|
||||
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
|
||||
| string
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
}
|
||||
|
||||
@customElement("notification-manager")
|
||||
@@ -93,31 +100,22 @@ class NotificationManager extends LitElement {
|
||||
)
|
||||
: this._parameters.message
|
||||
}
|
||||
.announceText=${
|
||||
this._parameters.announceMessage
|
||||
? typeof this._parameters.announceMessage !== "string"
|
||||
? this.hass.localize(
|
||||
this._parameters.announceMessage.translationKey,
|
||||
this._parameters.announceMessage.args
|
||||
)
|
||||
: this._parameters.announceMessage
|
||||
: undefined
|
||||
}
|
||||
.timeoutMs=${this._parameters.duration!}
|
||||
.bottomOffset=${this._parameters.bottomOffset ?? 0}
|
||||
@toast-closed=${this._toastClosed}
|
||||
>
|
||||
${
|
||||
this._parameters?.action
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
slot="action"
|
||||
@click=${this._buttonClicked}
|
||||
>
|
||||
${
|
||||
typeof this._parameters?.action.text !== "string"
|
||||
? this.hass.localize(
|
||||
this._parameters?.action.text.translationKey,
|
||||
this._parameters?.action.text.args
|
||||
)
|
||||
: this._parameters?.action.text
|
||||
}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._renderAction(this._parameters.secondaryAction, true)}
|
||||
${this._renderAction(this._parameters.action, false)}
|
||||
${
|
||||
this._parameters?.dismissable
|
||||
? html`
|
||||
@@ -134,11 +132,37 @@ class NotificationManager extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAction(
|
||||
action: ToastActionParams | undefined,
|
||||
secondary: boolean
|
||||
) {
|
||||
if (!action) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-button
|
||||
appearance=${action.primary ? "filled" : "plain"}
|
||||
size="s"
|
||||
slot="action"
|
||||
@click=${secondary ? this._secondaryButtonClicked : this._buttonClicked}
|
||||
>
|
||||
${
|
||||
typeof action.text !== "string"
|
||||
? this.hass.localize(action.text.translationKey, action.text.args)
|
||||
: action.text
|
||||
}
|
||||
</ha-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _buttonClicked() {
|
||||
this._toast?.hide("action");
|
||||
if (this._parameters?.action) {
|
||||
this._parameters?.action.action();
|
||||
}
|
||||
this._parameters?.action?.action();
|
||||
}
|
||||
|
||||
private _secondaryButtonClicked() {
|
||||
this._toast?.hide("action");
|
||||
this._parameters?.secondaryAction?.action();
|
||||
}
|
||||
|
||||
private _dismissClicked() {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { provide } from "@lit/context";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { LitElement } from "lit";
|
||||
import type { LitElement, PropertyValues } from "lit";
|
||||
import { state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { deepEqual } from "../common/util/deep-equal";
|
||||
import { shallowEqual } from "../common/util/shallow-equal";
|
||||
import {
|
||||
@@ -17,6 +18,18 @@ export type CompareStrategy<State> =
|
||||
| { type: "shallow" }
|
||||
| { type: "custom"; compare: (a: State, b: State) => boolean };
|
||||
|
||||
const connectedDirtyStateProviders = new Map<object, boolean>();
|
||||
|
||||
const publishGlobalDirtyState = (): void => {
|
||||
const isDirty = Array.from(connectedDirtyStateProviders.values()).some(
|
||||
Boolean
|
||||
);
|
||||
if (isDirty !== window.isDirtyState) {
|
||||
window.isDirtyState = isDirty;
|
||||
fireEvent(window, "dirty-state-changed", { isDirty });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mixin that provides dirty-state tracking via Lit context.
|
||||
*
|
||||
@@ -26,6 +39,9 @@ export type CompareStrategy<State> =
|
||||
* so independent contributors (e.g. a helper form alongside the entity
|
||||
* registry editor) can coexist without overwriting each other.
|
||||
*
|
||||
* Connected providers contribute to the global dirty state. It remains dirty
|
||||
* while any provider has unsaved changes.
|
||||
*
|
||||
* `isEffectiveDirty` runs the same comparison, but first passes each slice's
|
||||
* initial and current value through the optional `effectiveNormalize` function
|
||||
* given to `_initDirtyTracking`. Provide a normalizer that collapses values you
|
||||
@@ -118,6 +134,24 @@ export const DirtyStateProviderMixin =
|
||||
this._dirtyStateContext = this._buildContextValue();
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
connectedDirtyStateProviders.set(this, this.isDirtyState);
|
||||
publishGlobalDirtyState();
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>): void {
|
||||
super.updated(changedProperties);
|
||||
connectedDirtyStateProviders.set(this, this.isDirtyState);
|
||||
publishGlobalDirtyState();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
connectedDirtyStateProviders.delete(this);
|
||||
publishGlobalDirtyState();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private _writeSlice(key: Key | DefaultDirtyStateKey, value: State): void {
|
||||
const slice = this._dirtySlices.get(key);
|
||||
if (!slice) {
|
||||
|
||||
@@ -203,6 +203,7 @@ class DialogBackupOnboarding
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_step") && this._step === "key") {
|
||||
this._save();
|
||||
}
|
||||
|
||||
@@ -145,6 +145,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Re-apply the type filter from the URL when the page is (re)displayed,
|
||||
// e.g. when navigating back to a cached instance of this page with a
|
||||
// different `type` query param.
|
||||
this._setFiltersFromUrl();
|
||||
window.addEventListener("location-changed", this._locationChanged);
|
||||
window.addEventListener("popstate", this._popState);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
mdiPlus,
|
||||
mdiShape,
|
||||
mdiTune,
|
||||
mdiVectorPolyline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -145,10 +144,6 @@ export class MatterConfigDashboard extends LitElement {
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-header">
|
||||
${this.hass.localize("ui.panel.config.matter.panel.my_network_title")}
|
||||
<ha-button appearance="filled" href="/config/matter/visualization">
|
||||
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.matter.panel.show_map")}
|
||||
</ha-button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
@@ -257,9 +252,6 @@ export class MatterConfigDashboard extends LitElement {
|
||||
}
|
||||
|
||||
.nav-card .card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,6 @@ class MatterConfigRouter extends HassRouterPage {
|
||||
tag: "matter-options-page",
|
||||
load: () => import("./matter-options-page"),
|
||||
},
|
||||
visualization: {
|
||||
tag: "matter-network-visualization",
|
||||
load: () => import("./matter-network-visualization"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkLink,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterTopologyStrength,
|
||||
} from "../../../../../data/matter";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const CATEGORY_HOME_ASSISTANT = 0;
|
||||
const CATEGORY_BORDER_ROUTER = 1;
|
||||
const CATEGORY_ROUTER = 2;
|
||||
const CATEGORY_END_DEVICE = 3;
|
||||
const CATEGORY_WIFI_AP = 4;
|
||||
const CATEGORY_OFFLINE = 5;
|
||||
const CATEGORY_UNKNOWN = 6;
|
||||
|
||||
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
|
||||
|
||||
// HA is not a Matter node; the frontend synthesizes it as the graph root.
|
||||
export const HOME_ASSISTANT_NODE_ID = "ha";
|
||||
const HOME_ASSISTANT_LABEL = "Home Assistant";
|
||||
|
||||
// 0 is never returned: a falsy link value re-enables the direction arrow
|
||||
// in ha-network-graph
|
||||
export const strengthToScale = (
|
||||
strength?: MatterTopologyStrength | null
|
||||
): number => {
|
||||
switch (strength) {
|
||||
case "strong":
|
||||
return 4;
|
||||
case "medium":
|
||||
return 3;
|
||||
case "weak":
|
||||
return 2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
export const strengthToColorVar = (
|
||||
strength?: MatterTopologyStrength | null
|
||||
): string => {
|
||||
switch (strength) {
|
||||
case "strong":
|
||||
return "--success-color";
|
||||
case "medium":
|
||||
return "--warning-color";
|
||||
case "weak":
|
||||
return "--error-color";
|
||||
default:
|
||||
return "--disabled-color";
|
||||
}
|
||||
};
|
||||
|
||||
const strengthToWidth = (strength?: MatterTopologyStrength | null): number =>
|
||||
strength === "strong" ? 3 : strength === "medium" ? 2 : 1;
|
||||
|
||||
export const getTopologyNodeCategory = (
|
||||
node: MatterNetworkTopologyNode
|
||||
): number => {
|
||||
if (node.kind === "border_router") {
|
||||
return CATEGORY_BORDER_ROUTER;
|
||||
}
|
||||
if (node.kind === "wifi_ap") {
|
||||
return CATEGORY_WIFI_AP;
|
||||
}
|
||||
if (node.kind === "thread_unknown") {
|
||||
return CATEGORY_UNKNOWN;
|
||||
}
|
||||
if (node.available === false) {
|
||||
return CATEGORY_OFFLINE;
|
||||
}
|
||||
return node.role && ROUTER_ROLES.has(node.role)
|
||||
? CATEGORY_ROUTER
|
||||
: CATEGORY_END_DEVICE;
|
||||
};
|
||||
|
||||
export const getTopologyNodeName = (
|
||||
node: MatterNetworkTopologyNode,
|
||||
hass: HomeAssistant
|
||||
): string => {
|
||||
const device = node.ha_device_id
|
||||
? hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device) {
|
||||
return device.name_by_user || device.name || node.id;
|
||||
}
|
||||
if (node.kind === "border_router") {
|
||||
return (
|
||||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
|
||||
hass.localize("ui.panel.config.matter.visualization.border_router")
|
||||
);
|
||||
}
|
||||
if (node.kind === "wifi_ap") {
|
||||
return (
|
||||
node.network_name ||
|
||||
hass.localize("ui.panel.config.matter.visualization.wifi_ap")
|
||||
);
|
||||
}
|
||||
if (node.kind === "thread_unknown") {
|
||||
return hass.localize("ui.panel.config.matter.visualization.unknown_device");
|
||||
}
|
||||
if (node.node_id != null) {
|
||||
return hass.localize("ui.panel.config.matter.visualization.node", {
|
||||
node_id: node.node_id,
|
||||
});
|
||||
}
|
||||
return node.id;
|
||||
};
|
||||
|
||||
const isHub = (category: number): boolean =>
|
||||
category === CATEGORY_BORDER_ROUTER || category === CATEGORY_WIFI_AP;
|
||||
|
||||
export function createMatterNetworkChartData(
|
||||
topology: MatterNetworkTopology,
|
||||
hass: HomeAssistant,
|
||||
element: Element
|
||||
): NetworkData {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
const categoryColors = [
|
||||
style.getPropertyValue("--primary-color"),
|
||||
style.getPropertyValue("--deep-purple-color"),
|
||||
style.getPropertyValue("--cyan-color"),
|
||||
style.getPropertyValue("--teal-color"),
|
||||
style.getPropertyValue("--indigo-color"),
|
||||
style.getPropertyValue("--error-color"),
|
||||
style.getPropertyValue("--disabled-color"),
|
||||
];
|
||||
const categories = [
|
||||
{
|
||||
name: HOME_ASSISTANT_LABEL,
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.border_router"),
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: categoryColors[CATEGORY_BORDER_ROUTER] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.router"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_ROUTER] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.end_device"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_END_DEVICE] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.wifi_ap"),
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: categoryColors[CATEGORY_WIFI_AP] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.offline"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_OFFLINE] },
|
||||
},
|
||||
{
|
||||
name: hass.localize(
|
||||
"ui.panel.config.matter.visualization.unknown_devices"
|
||||
),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
|
||||
},
|
||||
];
|
||||
|
||||
const threadNetworks = new Set(
|
||||
topology.nodes.map((node) => node.ext_pan_id).filter(Boolean)
|
||||
);
|
||||
const multiNetwork = threadNetworks.size > 1;
|
||||
|
||||
const nodes: NetworkNode[] = [
|
||||
{
|
||||
id: HOME_ASSISTANT_NODE_ID,
|
||||
name: HOME_ASSISTANT_LABEL,
|
||||
category: CATEGORY_HOME_ASSISTANT,
|
||||
value: 4,
|
||||
symbol: "roundRect",
|
||||
symbolSize: 45,
|
||||
polarDistance: 0,
|
||||
fixed: true,
|
||||
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
|
||||
},
|
||||
];
|
||||
const nodeCategories = new Map<string, number>();
|
||||
topology.nodes.forEach((node) => {
|
||||
const category = getTopologyNodeCategory(node);
|
||||
nodeCategories.set(node.id, category);
|
||||
const device = node.ha_device_id
|
||||
? hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
const area = device ? getDeviceArea(device, hass.areas) : undefined;
|
||||
const contextParts: string[] = [];
|
||||
if (area) {
|
||||
contextParts.push(area.name);
|
||||
}
|
||||
if ((multiNetwork || !area) && node.network_name) {
|
||||
contextParts.push(node.network_name);
|
||||
}
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
name: getTopologyNodeName(node, hass),
|
||||
context: contextParts.join(" • ") || undefined,
|
||||
category,
|
||||
value: isHub(category) ? 3 : category === CATEGORY_ROUTER ? 2 : 1,
|
||||
symbol: isHub(category) ? "roundRect" : "circle",
|
||||
symbolSize: isHub(category) ? 40 : category === CATEGORY_ROUTER ? 30 : 20,
|
||||
itemStyle: {
|
||||
color: categoryColors[category],
|
||||
...(node.role === "leader"
|
||||
? {
|
||||
borderColor: style.getPropertyValue("--primary-color"),
|
||||
borderWidth: 2,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
polarDistance: isHub(category)
|
||||
? 0.1
|
||||
: category === CATEGORY_ROUTER
|
||||
? 0.4
|
||||
: 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
const links: NetworkLink[] = [];
|
||||
topology.connections.forEach((conn) => {
|
||||
if (!nodeCategories.has(conn.source) || !nodeCategories.has(conn.target)) {
|
||||
return;
|
||||
}
|
||||
let { source, target } = conn;
|
||||
let forward = conn.source_to_target;
|
||||
let reverse = conn.target_to_source;
|
||||
if (!forward && reverse) {
|
||||
// normalize so the arrow points in the observed direction
|
||||
[source, target] = [target, source];
|
||||
forward = reverse;
|
||||
reverse = undefined;
|
||||
}
|
||||
const oneWay = Boolean(forward) && !reverse;
|
||||
const asymmetric =
|
||||
forward && reverse && forward.strength !== reverse.strength;
|
||||
const width = strengthToWidth(conn.strength);
|
||||
links.push({
|
||||
source,
|
||||
target,
|
||||
value: strengthToScale(forward?.strength ?? conn.strength),
|
||||
// route-table edges without per-direction info are not directional
|
||||
reverseValue: oneWay
|
||||
? undefined
|
||||
: strengthToScale(reverse?.strength ?? conn.strength),
|
||||
symbolSize: oneWay ? width * 2 + 3 : undefined,
|
||||
lineStyle: {
|
||||
width,
|
||||
color: style.getPropertyValue(strengthToColorVar(conn.strength)),
|
||||
type:
|
||||
oneWay || asymmetric
|
||||
? "dashed"
|
||||
: !forward && conn.via_route_table
|
||||
? "dotted"
|
||||
: "solid",
|
||||
},
|
||||
ignoreForceLayout: !(
|
||||
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const haLink = (target: string): NetworkLink => ({
|
||||
source: HOME_ASSISTANT_NODE_ID,
|
||||
target,
|
||||
value: 0,
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: categoryColors[CATEGORY_HOME_ASSISTANT],
|
||||
},
|
||||
});
|
||||
|
||||
// HA reaches the mesh through the border routers and Wi-Fi access points
|
||||
const hubIds = topology.nodes
|
||||
.filter((node) => node.kind === "border_router" || node.kind === "wifi_ap")
|
||||
.map((node) => node.id);
|
||||
hubIds.forEach((id) => links.push(haLink(id)));
|
||||
|
||||
// any node group without a border router / AP is linked straight to HA so
|
||||
// it never floats free (HA has a direct operational path to every node)
|
||||
const adjacency = new Map<string, Set<string>>();
|
||||
topology.nodes.forEach((node) => adjacency.set(node.id, new Set()));
|
||||
links.forEach((link) => {
|
||||
if (link.source === HOME_ASSISTANT_NODE_ID) {
|
||||
return;
|
||||
}
|
||||
adjacency.get(link.source)?.add(link.target);
|
||||
adjacency.get(link.target)?.add(link.source);
|
||||
});
|
||||
const hubIdSet = new Set(hubIds);
|
||||
const visited = new Set<string>();
|
||||
topology.nodes.forEach((startNode) => {
|
||||
if (visited.has(startNode.id)) {
|
||||
return;
|
||||
}
|
||||
const component: string[] = [];
|
||||
const queue = [startNode.id];
|
||||
visited.add(startNode.id);
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
component.push(id);
|
||||
adjacency.get(id)?.forEach((next) => {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (component.some((id) => hubIdSet.has(id))) {
|
||||
return;
|
||||
}
|
||||
const candidates = component.filter(
|
||||
(id) => nodeCategories.get(id) !== CATEGORY_UNKNOWN
|
||||
);
|
||||
const representative = (candidates.length ? candidates : component).sort(
|
||||
(a, b) => (adjacency.get(b)?.size ?? 0) - (adjacency.get(a)?.size ?? 0)
|
||||
)[0];
|
||||
if (representative) {
|
||||
links.push(haLink(representative));
|
||||
}
|
||||
});
|
||||
|
||||
// keep the strongest link of every node in the force layout so
|
||||
// nodes hang near their best connection instead of floating free
|
||||
nodes.forEach((node) => {
|
||||
let bestLink: NetworkLink | undefined;
|
||||
const hasActiveLink = links.some((link) => {
|
||||
if (link.source !== node.id && link.target !== node.id) {
|
||||
return false;
|
||||
}
|
||||
if (!link.ignoreForceLayout) {
|
||||
return true;
|
||||
}
|
||||
const linkValue = Math.max(link.value ?? 0, link.reverseValue ?? 0);
|
||||
if (
|
||||
linkValue >
|
||||
Math.max(bestLink?.value ?? -1, bestLink?.reverseValue ?? -1)
|
||||
) {
|
||||
bestLink = link;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!hasActiveLink && bestLink) {
|
||||
bestLink.ignoreForceLayout = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
-515
@@ -1,515 +0,0 @@
|
||||
import { mdiRefresh } from "@mdi/js";
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { relativeTime } from "../../../../../common/datetime/relative_time";
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../../../../common/translations/localize";
|
||||
import { throttle } from "../../../../../common/util/throttle";
|
||||
import "../../../../../components/chart/ha-network-graph";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterTopologyDirectionInfo,
|
||||
} from "../../../../../data/matter";
|
||||
import {
|
||||
fetchMatterNetworkTopology,
|
||||
subscribeMatterNetworkTopology,
|
||||
} from "../../../../../data/matter";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import {
|
||||
createMatterNetworkChartData,
|
||||
HOME_ASSISTANT_NODE_ID,
|
||||
} from "./matter-network-data";
|
||||
|
||||
const UPDATE_THROTTLE_TIME = 5000;
|
||||
|
||||
@customElement("matter-network-visualization")
|
||||
export class MatterNetworkVisualization extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _topology?: MatterNetworkTopology;
|
||||
|
||||
@state() private _notSupported = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _refreshing = false;
|
||||
|
||||
@state() private _searchFilter = "";
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _throttledUpdateTopology = throttle(
|
||||
(topology: MatterNetworkTopology) => {
|
||||
this._topology = topology;
|
||||
},
|
||||
UPDATE_THROTTLE_TIME
|
||||
);
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hass && !this._unsub) {
|
||||
this._subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._throttledUpdateTopology.cancel();
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsub = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _subscribe(): void {
|
||||
this._unsub = subscribeMatterNetworkTopology(this.hass, (topology) => {
|
||||
if (!this._topology) {
|
||||
this._topology = topology;
|
||||
} else {
|
||||
this._throttledUpdateTopology(topology);
|
||||
}
|
||||
});
|
||||
this._unsub.catch((err: { code?: string; message?: string }) => {
|
||||
this._unsub = undefined;
|
||||
if (err?.code === "not_supported" || err?.code === "unknown_command") {
|
||||
this._notSupported = true;
|
||||
} else {
|
||||
this._error = err?.message || String(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.header"
|
||||
)}
|
||||
back-path="/config/matter/dashboard"
|
||||
>
|
||||
${
|
||||
this.narrow && this._topology?.nodes.length
|
||||
? html`<div slot="header">${this._renderInputSearch()}</div>`
|
||||
: nothing
|
||||
}
|
||||
${this._renderContent()}
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderContent() {
|
||||
if (this._notSupported) {
|
||||
return html`<div class="center">
|
||||
<ha-alert alert-type="info">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.not_supported"
|
||||
)}
|
||||
</ha-alert>
|
||||
</div>`;
|
||||
}
|
||||
if (this._error) {
|
||||
return html`<div class="center">
|
||||
<ha-alert alert-type="error">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.error_loading",
|
||||
{ error: this._error }
|
||||
)}
|
||||
</ha-alert>
|
||||
</div>`;
|
||||
}
|
||||
if (!this._topology) {
|
||||
return html`<div class="center"><ha-spinner></ha-spinner></div>`;
|
||||
}
|
||||
if (!this._topology.nodes.length) {
|
||||
return html`<div class="center empty">
|
||||
${this.hass.localize("ui.panel.config.matter.visualization.empty")}
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<ha-network-graph
|
||||
.hass=${this.hass}
|
||||
.searchFilter=${this._searchFilter}
|
||||
.data=${this._formatNetworkData(
|
||||
this._topology,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.themes,
|
||||
this.hass.language
|
||||
)}
|
||||
.searchableAttributes=${this._getSearchableAttributes}
|
||||
.tooltipFormatter=${this._tooltipFormatter}
|
||||
@chart-click=${this._handleChartClick}
|
||||
>
|
||||
${!this.narrow ? this._renderInputSearch("search") : nothing}
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class="refresh-button"
|
||||
.disabled=${this._refreshing}
|
||||
.path=${mdiRefresh}
|
||||
@click=${this._refreshTopology}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.refresh_topology"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</ha-network-graph>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderInputSearch(slot = "") {
|
||||
return html`<ha-input-search
|
||||
appearance="outlined"
|
||||
slot=${slot}
|
||||
.value=${this._searchFilter}
|
||||
@input=${this._handleSearchChange}
|
||||
></ha-input-search>`;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: InputEvent): void {
|
||||
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
|
||||
}
|
||||
|
||||
private async _refreshTopology(): Promise<void> {
|
||||
if (this._refreshing) {
|
||||
return;
|
||||
}
|
||||
this._refreshing = true;
|
||||
try {
|
||||
this._topology = await fetchMatterNetworkTopology(this.hass, true);
|
||||
} catch (err: unknown) {
|
||||
this._error = (err as { message?: string })?.message || String(err);
|
||||
} finally {
|
||||
this._refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _formatNetworkData = memoizeOne(
|
||||
(
|
||||
topology: MatterNetworkTopology,
|
||||
_devices: HomeAssistant["devices"],
|
||||
_areas: HomeAssistant["areas"],
|
||||
// node/link colors and labels also depend on the theme and language,
|
||||
// so both take part in the cache key even though they are read via hass
|
||||
_themes: HomeAssistant["themes"],
|
||||
_language: HomeAssistant["language"]
|
||||
) => createMatterNetworkChartData(topology, this.hass, this)
|
||||
);
|
||||
|
||||
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
|
||||
return this._topology?.nodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
private _getConnection(
|
||||
source: string,
|
||||
target: string
|
||||
): MatterNetworkTopologyConnection | undefined {
|
||||
return this._topology?.connections.find(
|
||||
(conn) =>
|
||||
(conn.source === source && conn.target === target) ||
|
||||
(conn.source === target && conn.target === source)
|
||||
);
|
||||
}
|
||||
|
||||
private _getNodeName(id: string): string {
|
||||
const node = this._getTopologyNode(id);
|
||||
if (!node) {
|
||||
return id;
|
||||
}
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device) {
|
||||
return device.name_by_user || device.name || id;
|
||||
}
|
||||
if (node.kind === "border_router") {
|
||||
return (
|
||||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
|
||||
this.hass.localize("ui.panel.config.matter.visualization.border_router")
|
||||
);
|
||||
}
|
||||
if (node.kind === "wifi_ap") {
|
||||
return (
|
||||
node.network_name ||
|
||||
this.hass.localize("ui.panel.config.matter.visualization.wifi_ap")
|
||||
);
|
||||
}
|
||||
if (node.kind === "thread_unknown") {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.unknown_device"
|
||||
);
|
||||
}
|
||||
if (node.node_id != null) {
|
||||
return this.hass.localize("ui.panel.config.matter.visualization.node", {
|
||||
node_id: node.node_id,
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private _getSearchableAttributes = (nodeId: string): string[] => {
|
||||
const node = this._getTopologyNode(nodeId);
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
const attributes: string[] = [];
|
||||
if (node.node_id != null) {
|
||||
attributes.push(String(node.node_id));
|
||||
}
|
||||
if (node.network_name) {
|
||||
attributes.push(node.network_name);
|
||||
}
|
||||
if (node.ext_address) {
|
||||
attributes.push(node.ext_address);
|
||||
}
|
||||
if (node.vendor_name) {
|
||||
attributes.push(node.vendor_name);
|
||||
}
|
||||
if (node.model_name) {
|
||||
attributes.push(node.model_name);
|
||||
}
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device?.manufacturer) {
|
||||
attributes.push(device.manufacturer);
|
||||
}
|
||||
if (device?.model) {
|
||||
attributes.push(device.model);
|
||||
}
|
||||
device?.connections.forEach((connection) => {
|
||||
attributes.push(connection[1]);
|
||||
});
|
||||
return attributes;
|
||||
};
|
||||
|
||||
private _localizeDynamic(prefix: string, value: string): string {
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.matter.${prefix}.${value}` as LocalizeKeys
|
||||
) || value
|
||||
);
|
||||
}
|
||||
|
||||
private _formatDirection(direction: MatterTopologyDirectionInfo): string {
|
||||
const strength = this._localizeDynamic(
|
||||
"visualization.strength",
|
||||
direction.strength
|
||||
);
|
||||
if (direction.lqi != null) {
|
||||
return `${strength} (LQI ${direction.lqi})`;
|
||||
}
|
||||
if (direction.rssi != null) {
|
||||
return `${strength} (RSSI ${direction.rssi} dBm)`;
|
||||
}
|
||||
return strength;
|
||||
}
|
||||
|
||||
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
|
||||
const { dataType, data } = params as CallbackDataParams;
|
||||
if (dataType === "edge") {
|
||||
const { source, target } = data as { source: string; target: string };
|
||||
const conn = this._getConnection(source, target);
|
||||
if (!conn) {
|
||||
return nothing;
|
||||
}
|
||||
const lines: TemplateResult[] = [];
|
||||
if (conn.source_to_target) {
|
||||
lines.push(
|
||||
html`<br />${this._getNodeName(conn.source)} →
|
||||
${this._getNodeName(conn.target)}:
|
||||
${this._formatDirection(conn.source_to_target)}`
|
||||
);
|
||||
}
|
||||
if (conn.target_to_source) {
|
||||
lines.push(
|
||||
html`<br />${this._getNodeName(conn.target)} →
|
||||
${this._getNodeName(conn.source)}:
|
||||
${this._formatDirection(conn.target_to_source)}`
|
||||
);
|
||||
}
|
||||
if (!lines.length && conn.via_route_table) {
|
||||
lines.push(
|
||||
html`<br />${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.via_route_table"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
return html`<b
|
||||
>${this._getNodeName(conn.source)} ↔
|
||||
${this._getNodeName(conn.target)}</b
|
||||
>${lines}`;
|
||||
}
|
||||
const { id } = data as { id: string };
|
||||
if (id === HOME_ASSISTANT_NODE_ID) {
|
||||
return html`<b>Home Assistant</b>`;
|
||||
}
|
||||
const node = this._getTopologyNode(id);
|
||||
if (!node) {
|
||||
return nothing;
|
||||
}
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
|
||||
const lines: TemplateResult[] = [];
|
||||
if (node.node_id != null) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.node_id"
|
||||
)}:</b
|
||||
>
|
||||
${node.node_id}`
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.network"
|
||||
)}:</b
|
||||
>
|
||||
${this._localizeDynamic("network_type", node.network_type)}${
|
||||
node.network_name ? html` (${node.network_name})` : nothing
|
||||
}`
|
||||
);
|
||||
if (node.role) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.role"
|
||||
)}:</b
|
||||
>
|
||||
${this._localizeDynamic("visualization.roles", node.role)}`
|
||||
);
|
||||
}
|
||||
if (node.available != null) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.status"
|
||||
)}:</b
|
||||
>
|
||||
${this.hass.localize(
|
||||
node.available
|
||||
? "ui.panel.config.matter.visualization.online"
|
||||
: "ui.panel.config.matter.visualization.offline"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (device?.manufacturer || node.vendor_name) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.manufacturer"
|
||||
)}:</b
|
||||
>
|
||||
${device?.manufacturer || node.vendor_name}`
|
||||
);
|
||||
}
|
||||
if (device?.model || node.model_name) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.model"
|
||||
)}:</b
|
||||
>
|
||||
${device?.model || node.model_name}`
|
||||
);
|
||||
}
|
||||
if (area) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.area"
|
||||
)}:</b
|
||||
>
|
||||
${area.name}`
|
||||
);
|
||||
}
|
||||
if (node.last_seen != null) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.last_seen"
|
||||
)}:</b
|
||||
>
|
||||
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
|
||||
);
|
||||
}
|
||||
return html`<b>${this._getNodeName(id)}</b>${lines}`;
|
||||
};
|
||||
|
||||
private _handleChartClick(e: CustomEvent): void {
|
||||
if (
|
||||
e.detail.dataType === "node" &&
|
||||
e.detail.event.target.cursor === "pointer"
|
||||
) {
|
||||
const { id } = e.detail.data;
|
||||
const node = this._getTopologyNode(id);
|
||||
if (node?.ha_device_id) {
|
||||
navigate(`/config/devices/device/${node.ha_device_id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
ha-network-graph {
|
||||
height: 100%;
|
||||
}
|
||||
[slot="header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-input-search {
|
||||
flex: 1;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
ha-alert {
|
||||
max-width: 500px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"matter-network-visualization": MatterNetworkVisualization;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
HTTP_CONFIG_FIELDS,
|
||||
saveHttpConfig,
|
||||
} from "../../../data/http";
|
||||
import type { HttpConfig } from "../../../data/http";
|
||||
import type {
|
||||
ActiveConfigType,
|
||||
HttpConfig,
|
||||
HttpConfigWithMeta,
|
||||
} from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -161,6 +165,11 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
@state() private _showNoChanges = false;
|
||||
|
||||
@state() private _activeConfigType?: ActiveConfigType;
|
||||
|
||||
// A pending config that was reverted/failed and kept only for display.
|
||||
@state() private _revertedPending?: HttpConfigWithMeta;
|
||||
|
||||
@query("ha-form") private _form?: HaForm;
|
||||
|
||||
@query("ha-alert") private _firstAlert?: HTMLElement;
|
||||
@@ -201,6 +210,40 @@ class HaConfigHttpForm extends LitElement {
|
||||
<p class="description">
|
||||
${this.hass.localize("ui.panel.config.network.http.description")}
|
||||
</p>
|
||||
${
|
||||
this._activeConfigType === "default"
|
||||
? html`
|
||||
<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.running_default"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._revertedPending
|
||||
? html`
|
||||
<ha-alert alert-type="warning">
|
||||
${
|
||||
this._revertedPending.error === "not_promoted"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.network.http.reverted_not_confirmed"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.network.http.reverted_failed",
|
||||
{ error: this._revertedPending.error ?? "" }
|
||||
)
|
||||
}
|
||||
<ha-button slot="action" @click=${this._reviewReverted}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.reverted_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
portChanged
|
||||
? html`
|
||||
@@ -266,16 +309,30 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
private async _fetchConfig(): Promise<void> {
|
||||
try {
|
||||
// Pending is exclusively handled by the global confirm/revert dialog, so
|
||||
// the form only ever displays stable.
|
||||
const { stable } = await fetchHttpConfig(this.hass);
|
||||
const { stable, pending, active_config_type } = await fetchHttpConfig(
|
||||
this.hass
|
||||
);
|
||||
this._stable = stable;
|
||||
this._config = { ...stable };
|
||||
this._activeConfigType = active_config_type;
|
||||
// An active trial pending (no error) is handled by the global
|
||||
// confirm/revert dialog. A pending carrying an error was reverted or
|
||||
// failed to apply and is kept only so we can surface it here.
|
||||
this._revertedPending = pending?.error ? pending : undefined;
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _reviewReverted(): void {
|
||||
if (!this._revertedPending) {
|
||||
return;
|
||||
}
|
||||
// Load the reverted values into the form so the user can fix and re-save.
|
||||
this._config = { ...this._revertedPending };
|
||||
this._revertedPending = undefined;
|
||||
}
|
||||
|
||||
private _computeLabel = (
|
||||
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
|
||||
): string => {
|
||||
|
||||
@@ -16,7 +16,11 @@ import "../../../components/ha-svg-icon";
|
||||
import { apiContext } from "../../../data/context";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type { LawnMowerEntity } from "../../../data/lawn_mower";
|
||||
import { LawnMowerEntityFeature, canDock } from "../../../data/lawn_mower";
|
||||
import {
|
||||
LawnMowerEntityFeature,
|
||||
canDock,
|
||||
canStartMowing,
|
||||
} from "../../../data/lawn_mower";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../../../types";
|
||||
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
|
||||
import { cardFeatureStyles } from "./common/card-feature-styles";
|
||||
@@ -72,6 +76,9 @@ export const LAWN_MOWER_COMMANDS_BUTTONS: Record<
|
||||
translationKey: "start",
|
||||
icon: mdiPlay,
|
||||
serviceName: "start_mowing",
|
||||
disabled:
|
||||
!supportsFeature(stateObj, LawnMowerEntityFeature.START_MOWING) ||
|
||||
!canStartMowing(stateObj),
|
||||
};
|
||||
},
|
||||
dock: (stateObj) => ({
|
||||
|
||||
@@ -34,6 +34,8 @@ export const supportsTrendGraphCardFeature = (
|
||||
|
||||
export const DEFAULT_HOURS_TO_SHOW = 24;
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
@customElement("hui-trend-graph-card-feature")
|
||||
class HuiHistoryChartCardFeature
|
||||
extends LitElement
|
||||
@@ -93,9 +95,13 @@ class HuiHistoryChartCardFeature
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// redraw the graph every minute to update the time axis
|
||||
// recompute the graph every minute so the x-axis (and the horizontal fill
|
||||
// to now) keeps advancing even while the sensor value stays constant
|
||||
clearInterval(this._interval);
|
||||
this._interval = window.setInterval(() => this.requestUpdate(), 1000 * 60);
|
||||
this._interval = window.setInterval(
|
||||
() => this._calculateCoordinates(),
|
||||
1000 * 60
|
||||
);
|
||||
if (this.hasUpdated) {
|
||||
this._subscribeHistory();
|
||||
}
|
||||
@@ -146,12 +152,18 @@ class HuiHistoryChartCardFeature
|
||||
? Math.max(10, width / 5, hourToShow)
|
||||
: Math.max(10, hourToShow);
|
||||
const useMean = !detail;
|
||||
// Anchor the x-axis to the full time window so a constant value is drawn as
|
||||
// a horizontal line up to now, instead of ending at the last state change.
|
||||
const now = Date.now();
|
||||
const { points, yAxisOrigin } = coordinatesMinimalResponseCompressedState(
|
||||
this._stateHistory,
|
||||
width,
|
||||
height,
|
||||
maxDetails,
|
||||
undefined,
|
||||
{
|
||||
minX: now - hourToShow * HOUR,
|
||||
maxX: now,
|
||||
},
|
||||
useMean
|
||||
);
|
||||
this._coordinates = points;
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { getGraphColorByIndex } from "../../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../../common/entity/context/get_entity_context";
|
||||
import type {
|
||||
Link,
|
||||
Node,
|
||||
} from "../../../../../components/chart/ha-sankey-chart";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../../data/energy";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
// Devices below this fraction of the home total are grouped into an "Other" node
|
||||
export const MIN_SANKEY_THRESHOLD_FACTOR = 0.001; // 0.1% of home total
|
||||
|
||||
// While the graph is assembled, device nodes carry an ad-hoc `parent` field
|
||||
// that is not part of the chart's Node interface. The chart ignores it.
|
||||
export type SankeyDeviceNode = Node & { parent?: string };
|
||||
|
||||
interface SmallConsumer {
|
||||
id: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
const OTHER_LABEL_KEY =
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other";
|
||||
const UNTRACKED_LABEL_KEY =
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption";
|
||||
|
||||
/** Open more-info for a clicked sankey node that maps to an entity. */
|
||||
export const fireSankeyNodeMoreInfo = (el: HTMLElement, node: Node): void => {
|
||||
if (node.entityId) {
|
||||
fireEvent(el, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
};
|
||||
|
||||
export interface BuildSankeyDeviceNodesOptions {
|
||||
devices: DeviceConsumptionEnergyPreference[];
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
localize: HomeAssistant["localize"];
|
||||
/** Node small consumers and untracked flow attach to (usually "home"). */
|
||||
rootNodeId: string;
|
||||
/** Flows below this value are grouped into an "Other" node. */
|
||||
minThreshold: number;
|
||||
/** Per-parent untracked residual only shown when above this value. */
|
||||
untrackedFloor: number;
|
||||
/** Round the "Other" node value up (instantaneous cards only). */
|
||||
ceilOtherValue: boolean;
|
||||
/** Starting untracked total (the home/root value). */
|
||||
initialUntracked: number;
|
||||
getId: (device: DeviceConsumptionEnergyPreference) => string | undefined;
|
||||
getValue: (id: string) => number;
|
||||
getLabel: (id: string, name: string | undefined) => string;
|
||||
getEntityId: (id: string) => string | undefined;
|
||||
findEffectiveParent: (
|
||||
includedInStat: string | undefined
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the device layer of a sankey card: one node per device, small devices
|
||||
* folded into per-parent "Other" nodes, and per-parent untracked residuals.
|
||||
* Value/label/id/entityId are supplied via callbacks so both cumulative
|
||||
* (statistic growth) and instantaneous (live power/flow) cards can share it.
|
||||
*/
|
||||
export const buildSankeyDeviceNodes = (
|
||||
options: BuildSankeyDeviceNodesOptions
|
||||
): {
|
||||
deviceNodes: SankeyDeviceNode[];
|
||||
parentLinks: Record<string, string>;
|
||||
links: Link[];
|
||||
untrackedConsumption: number;
|
||||
} => {
|
||||
const {
|
||||
devices,
|
||||
computedStyle,
|
||||
localize,
|
||||
rootNodeId,
|
||||
minThreshold,
|
||||
untrackedFloor,
|
||||
ceilOtherValue,
|
||||
initialUntracked,
|
||||
getId,
|
||||
getValue,
|
||||
getLabel,
|
||||
getEntityId,
|
||||
findEffectiveParent,
|
||||
} = options;
|
||||
|
||||
const unavailableColor = computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim();
|
||||
|
||||
const links: Link[] = [];
|
||||
const deviceNodes: SankeyDeviceNode[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
let untrackedConsumption = initialUntracked;
|
||||
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const value = getValue(id);
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minThreshold) {
|
||||
// Collect small consumers instead of folding them into untracked
|
||||
const parentKey = effectiveParent ?? rootNodeId;
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
id,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node: SankeyDeviceNode = {
|
||||
id,
|
||||
label: getLabel(id, device.name),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: getEntityId(id),
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Process small consumers - show a lone one directly, group clusters as "Other"
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumers.length === 1) {
|
||||
const consumer = consumers[0];
|
||||
const node: SankeyDeviceNode = {
|
||||
id: consumer.id,
|
||||
label: getLabel(consumer.id, consumer.name),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: getEntityId(consumer.id),
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: SankeyDeviceNode = {
|
||||
id: otherNodeId,
|
||||
label: localize(OTHER_LABEL_KEY),
|
||||
value: ceilOtherValue ? Math.ceil(totalValue) : totalValue,
|
||||
color: unavailableColor,
|
||||
index: 4,
|
||||
};
|
||||
if (parentKey !== rootNodeId) {
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({ source: parentKey, target: otherNodeId });
|
||||
} else {
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > untrackedFloor) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: localize(UNTRACKED_LABEL_KEY),
|
||||
value: untracked,
|
||||
color: unavailableColor,
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { deviceNodes, parentLinks, links, untrackedConsumption };
|
||||
};
|
||||
|
||||
/** Bucket device nodes by their entity's area and floor. */
|
||||
export const groupSankeyDevicesByFloorAndArea = (
|
||||
hass: HomeAssistant,
|
||||
deviceNodes: Node[]
|
||||
): {
|
||||
areas: Record<string, { value: number; devices: Node[] }>;
|
||||
floors: Record<string, { value: number; areas: string[] }>;
|
||||
} => {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
};
|
||||
|
||||
/**
|
||||
* Organize device nodes into hierarchical sections based on parent-child
|
||||
* relationships (top-level parents first, then their children, recursively).
|
||||
*/
|
||||
export const getSankeyDeviceSections = (
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] => {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: Record<string, string> = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...getSankeyDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
};
|
||||
|
||||
export interface BuildSankeyLayoutOptions {
|
||||
hass: HomeAssistant;
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
localize: HomeAssistant["localize"];
|
||||
deviceNodes: SankeyDeviceNode[];
|
||||
parentLinks: Record<string, string>;
|
||||
rootNodeId: string;
|
||||
groupByFloor: boolean;
|
||||
groupByArea: boolean;
|
||||
untrackedConsumption: number;
|
||||
untrackedFloor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the floor/area grouping layer, emit the device section nodes with
|
||||
* their final section indices, and add the top-level untracked node.
|
||||
*/
|
||||
export const buildSankeyLayout = (
|
||||
options: BuildSankeyLayoutOptions
|
||||
): { nodes: Node[]; links: Link[] } => {
|
||||
const {
|
||||
hass,
|
||||
computedStyle,
|
||||
localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId,
|
||||
groupByFloor,
|
||||
groupByArea,
|
||||
untrackedConsumption,
|
||||
untrackedFloor,
|
||||
} = options;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
const primaryColor = computedStyle.getPropertyValue("--primary-color").trim();
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
|
||||
if (groupByArea || groupByFloor) {
|
||||
const { areas, floors } = groupSankeyDevicesByFloorAndArea(
|
||||
hass,
|
||||
devicesWithoutParent
|
||||
);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(hass.floors[b]?.level ?? -Infinity) -
|
||||
(hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !groupByFloor) {
|
||||
// link "no_floor" areas to the root
|
||||
floorNodeId = rootNodeId;
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !groupByArea) {
|
||||
// If group_by_area is false, link devices to floor or root
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or root)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const deviceSections = getSankeyDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
});
|
||||
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > untrackedFloor) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: localize(UNTRACKED_LABEL_KEY),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle.getPropertyValue("--state-unavailable-color").trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
@@ -25,10 +24,14 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { EnergySankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<EnergySankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
@@ -138,6 +141,8 @@ class HuiEnergySankeyCard
|
||||
};
|
||||
nodes.push(homeNode);
|
||||
|
||||
const minEnergyThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
if (types.battery) {
|
||||
const totalBatteryOut = summedData.total.from_battery ?? 0;
|
||||
const totalBatteryIn = summedData.total.to_battery ?? 0;
|
||||
@@ -262,185 +267,85 @@ class HuiEnergySankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
prefs.device_consumption.forEach((device, idx) => {
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
) || 0
|
||||
: 0;
|
||||
if (value < 0.01) {
|
||||
return;
|
||||
const deviceValue = (statConsumption: string) =>
|
||||
statConsumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minEnergyThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
const node = {
|
||||
id: device.stat_consumption,
|
||||
label:
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this._data!.statsMetadata[device.stat_consumption]
|
||||
),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: device.included_in_stat,
|
||||
entityId: isExternalStatistic(device.stat_consumption)
|
||||
? undefined
|
||||
: device.stat_consumption,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
this._data!.statsMetadata[statConsumption]
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > 0) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minEnergyThreshold,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
// If group_by_area is false, link devices to floor or home
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]!.name,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or home)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: "home",
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const layout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > 0) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...layout.nodes);
|
||||
links.push(...layout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -480,113 +385,7 @@ class HuiEnergySankeyCard
|
||||
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`;
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes device nodes into hierarchical sections based on parent-child relationships.
|
||||
*/
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
formatPowerShort,
|
||||
@@ -19,19 +18,19 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { PowerSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
// Minimum power threshold as a fraction of total consumption to display a device node
|
||||
// Devices below this threshold will be grouped into an "Other" node
|
||||
const MIN_POWER_THRESHOLD_FACTOR = 0.001; // 0.1% of used_total
|
||||
|
||||
interface PowerData {
|
||||
solar: number;
|
||||
from_grid: number;
|
||||
@@ -48,14 +47,6 @@ interface PowerData {
|
||||
used_total: number;
|
||||
}
|
||||
|
||||
interface SmallConsumer {
|
||||
statRate: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("hui-power-sankey-card")
|
||||
class HuiPowerSankeyCard
|
||||
extends SubscribeMixin(MobileAwareMixin(LitElement))
|
||||
@@ -163,7 +154,8 @@ class HuiPowerSankeyCard
|
||||
const computedStyle = getComputedStyle(this);
|
||||
|
||||
// Calculate dynamic threshold based on total consumption
|
||||
const minPowerThreshold = powerData.used_total * MIN_POWER_THRESHOLD_FACTOR;
|
||||
const minPowerThreshold =
|
||||
powerData.used_total * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
@@ -286,10 +278,6 @@ class HuiPowerSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
|
||||
// Build a map of device relationships for hierarchy resolution
|
||||
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
|
||||
const deviceMap = new Map<
|
||||
@@ -338,252 +326,43 @@ class HuiPowerSankeyCard
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Collect small consumers by their effective parent
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
|
||||
prefs.device_consumption.forEach((device, idx) => {
|
||||
if (!device.stat_rate) {
|
||||
return;
|
||||
}
|
||||
const value = this._getCurrentPower(device.stat_rate);
|
||||
|
||||
// Find the effective parent (may be different from direct parent if parent has no stat_rate)
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minPowerThreshold) {
|
||||
// Collect small consumers instead of skipping them
|
||||
const parentKey = effectiveParent ?? "home";
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
statRate: device.stat_rate,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: device.stat_rate,
|
||||
label: device.name || this._getEntityLabel(device.stat_rate),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: device.stat_rate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minPowerThreshold,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentPower(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
|
||||
// Process small consumers - create "Other" nodes or show single entities
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumers.length === 1) {
|
||||
// Single entity - show it directly instead of grouping
|
||||
const consumer = consumers[0];
|
||||
const node = {
|
||||
id: consumer.statRate,
|
||||
label: consumer.name || this._getEntityLabel(consumer.statRate),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: consumer.statRate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
// Multiple entities - create "Other" group
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: Node = {
|
||||
id: otherNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
|
||||
),
|
||||
value: Math.ceil(totalValue),
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
};
|
||||
|
||||
if (parentKey !== "home") {
|
||||
// Has a parent device
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({
|
||||
source: parentKey,
|
||||
target: otherNodeId,
|
||||
});
|
||||
} else {
|
||||
// Top-level "Other" - will be linked to home/floor/area later
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full power
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
// only show if larger than 1W
|
||||
if (untracked > 1) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
// If group_by_area is false, link devices to floor or home
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or home)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: "home",
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const layout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
|
||||
// untracked consumption (only show if larger than 1W)
|
||||
if (untrackedConsumption > 1) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...layout.nodes);
|
||||
links.push(...layout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -623,10 +402,7 @@ class HuiPowerSankeyCard
|
||||
formatPowerShort(this.hass, value);
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -758,109 +534,6 @@ class HuiPowerSankeyCard
|
||||
};
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes device nodes into hierarchical sections based on parent-child relationships.
|
||||
*/
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current power value from entity state, normalized to watts (W)
|
||||
* @param entityId - The entity ID to get power value from
|
||||
|
||||
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
formatFlowRateShort,
|
||||
@@ -18,27 +17,19 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { WaterFlowSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<WaterFlowSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
// Minimum flow threshold as a fraction of total inflow to display a device node.
|
||||
// Devices below this threshold will be grouped into an "Other" node.
|
||||
const MIN_FLOW_THRESHOLD_FACTOR = 0.001; // 0.1% of total inflow
|
||||
|
||||
interface SmallConsumer {
|
||||
statRate: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("hui-water-flow-sankey-card")
|
||||
class HuiWaterFlowSankeyCard
|
||||
extends SubscribeMixin(MobileAwareMixin(LitElement))
|
||||
@@ -177,7 +168,7 @@ class HuiWaterFlowSankeyCard
|
||||
waterSources.length === 0 ? totalDeviceFlow : totalInflow;
|
||||
|
||||
// Calculate dynamic threshold
|
||||
const minFlowThreshold = effectiveTotalInflow * MIN_FLOW_THRESHOLD_FACTOR;
|
||||
const minFlowThreshold = effectiveTotalInflow * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
@@ -292,230 +283,43 @@ class HuiWaterFlowSankeyCard
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let untrackedConsumption = effectiveTotalInflow;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
|
||||
prefs.device_consumption_water.forEach((device, idx) => {
|
||||
if (!device.stat_rate) return;
|
||||
const value = this._getCurrentFlowRate(device.stat_rate);
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minFlowThreshold) {
|
||||
const parentKey = effectiveParent ?? rootNodeId;
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
statRate: device.stat_rate,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: device.stat_rate,
|
||||
label: device.name || this._getEntityLabel(device.stat_rate),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: device.stat_rate,
|
||||
};
|
||||
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption_water,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId,
|
||||
minThreshold: minFlowThreshold,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: effectiveTotalInflow,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentFlowRate(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
|
||||
// Process small consumers
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) return;
|
||||
|
||||
if (consumers.length === 1) {
|
||||
const consumer = consumers[0];
|
||||
const node = {
|
||||
id: consumer.statRate,
|
||||
label: consumer.name || this._getEntityLabel(consumer.statRate),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: consumer.statRate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: Node = {
|
||||
id: otherNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
|
||||
),
|
||||
value: Math.ceil(totalValue),
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
};
|
||||
|
||||
if (parentKey !== rootNodeId) {
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({ source: parentKey, target: otherNodeId });
|
||||
} else {
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full flow
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
// only show if larger than 1 L/min
|
||||
if (untracked > 1) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor, layout, title } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
floorNodeId = rootNodeId;
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({ source: rootNodeId, target: floorNodeId });
|
||||
}
|
||||
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const sankeyLayout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId,
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
|
||||
// Untracked consumption (only show if > 1 L/min threshold)
|
||||
if (untrackedConsumption > 1) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...sankeyLayout.nodes);
|
||||
links.push(...sankeyLayout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -557,10 +361,7 @@ class HuiWaterFlowSankeyCard
|
||||
);
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
private _getCurrentFlowRate(entityId: string): number {
|
||||
@@ -574,98 +375,6 @@ class HuiWaterFlowSankeyCard
|
||||
return stateObj.attributes.friendly_name || entityId;
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: { value: 0, devices: [] },
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: { value: 0, areas: ["no_area"] },
|
||||
};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
return [deviceNodes];
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
height: 400px;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
getEnergyDataCollection,
|
||||
@@ -22,10 +21,14 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { WaterSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<WaterSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
@@ -165,6 +168,8 @@ class HuiWaterSankeyCard
|
||||
};
|
||||
nodes.push(homeNode);
|
||||
|
||||
const minWaterThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
// Add water source nodes
|
||||
const waterColor = computedStyle
|
||||
.getPropertyValue("--energy-water-color")
|
||||
@@ -180,7 +185,7 @@ class HuiWaterSankeyCard
|
||||
) || 0
|
||||
: 0;
|
||||
|
||||
if (value < 0.01) {
|
||||
if (value <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,185 +210,85 @@ class HuiWaterSankeyCard
|
||||
});
|
||||
});
|
||||
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
prefs.device_consumption_water.forEach((device, idx) => {
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
) || 0
|
||||
: 0;
|
||||
if (value < 0.01) {
|
||||
return;
|
||||
const deviceValue = (statConsumption: string) =>
|
||||
statConsumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minWaterThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
const node = {
|
||||
id: device.stat_consumption,
|
||||
label:
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this._data!.statsMetadata[device.stat_consumption]
|
||||
),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: device.included_in_stat,
|
||||
entityId: isExternalStatistic(device.stat_consumption)
|
||||
? undefined
|
||||
: device.stat_consumption,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
this._data!.statsMetadata[statConsumption]
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > 0) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption_water,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minWaterThreshold,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
// If group_by_area is false, link devices to floor or home
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]!.name,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or home)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: "home",
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const layout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > 0) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...layout.nodes);
|
||||
links.push(...layout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -423,113 +328,7 @@ class HuiWaterSankeyCard
|
||||
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} ${this._data!.waterUnit}`;
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes device nodes into hierarchical sections based on parent-child relationships.
|
||||
*/
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -146,6 +146,7 @@ export class HuiDialogEditBadge
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (!changedProps.has("_badgeConfig")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ export class HuiDialogEditCard
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (!this._cardConfig || !changedProps.has("_cardConfig")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export class HuiDialogEditSection
|
||||
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const sectionConfig = {
|
||||
...this._config,
|
||||
|
||||
@@ -94,6 +94,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const viewConfig = {
|
||||
...this._config,
|
||||
|
||||
@@ -43,6 +43,7 @@ export class HuiDialogEditViewFooter extends DirtyStateProviderMixin<LovelaceVie
|
||||
@state() private _open = false;
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const config = {
|
||||
...this._config,
|
||||
|
||||
@@ -44,6 +44,7 @@ export class HuiDialogEditViewHeader extends DirtyStateProviderMixin<LovelaceVie
|
||||
@state() private _open = false;
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const config = {
|
||||
...this._config,
|
||||
|
||||
@@ -7,6 +7,7 @@ import "../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../components/input/ha-input";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
import { setValue } from "../../../data/input_text";
|
||||
import { showNumberSlider } from "../../../data/number";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { hasConfigOrEntityChanged } from "../common/has-changed";
|
||||
import "../components/hui-generic-entity-row";
|
||||
@@ -75,12 +76,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
return html`
|
||||
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
|
||||
${
|
||||
stateObj.attributes.mode === "slider" ||
|
||||
(stateObj.attributes.mode === "auto" &&
|
||||
(Number(stateObj.attributes.max) -
|
||||
Number(stateObj.attributes.min)) /
|
||||
Number(stateObj.attributes.step) <=
|
||||
256)
|
||||
showNumberSlider(stateObj)
|
||||
? html`
|
||||
<div class="flex">
|
||||
<ha-slider
|
||||
@@ -111,7 +107,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
.step=${Number(stateObj.attributes.step)}
|
||||
.min=${Number(stateObj.attributes.min)}
|
||||
.max=${Number(stateObj.attributes.max)}
|
||||
.value=${stateObj.state}
|
||||
.value=${Number(stateObj.state).toString()}
|
||||
type="number"
|
||||
@change=${this._selectedValueChanged}
|
||||
>
|
||||
|
||||
@@ -104,6 +104,7 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
const oldLovelace = changedProps.get("lovelace") as Lovelace | undefined;
|
||||
if (
|
||||
!this._saving &&
|
||||
|
||||
@@ -101,8 +101,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
const columns = Math.floor(
|
||||
(totalWidth - padding + columnGap) / (minColumnWidth + columnGap)
|
||||
);
|
||||
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
|
||||
return clamp(columns, 1, maxColumns);
|
||||
return Math.max(columns, 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -155,7 +154,14 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
|
||||
private _updateMaxColumnCount(): void {
|
||||
const maxColumnCount = this._columnsController.value ?? 1;
|
||||
// The column count is clamped here instead of in the resize callback, so
|
||||
// that it follows the config of the view the element is currently used for
|
||||
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
|
||||
const maxColumnCount = clamp(
|
||||
this._columnsController.value ?? 1,
|
||||
1,
|
||||
maxColumns
|
||||
);
|
||||
|
||||
if (maxColumnCount !== this._maxColumns) {
|
||||
this._maxColumns = maxColumnCount;
|
||||
|
||||
@@ -29,7 +29,7 @@ class StateCardLock extends LitElement {
|
||||
.inDialog=${this.inDialog}
|
||||
></state-info>
|
||||
${
|
||||
!supportsOpen
|
||||
supportsOpen
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
|
||||
@@ -7,6 +7,7 @@ import "../components/entity/state-info";
|
||||
import "../components/ha-slider";
|
||||
import "../components/input/ha-input";
|
||||
import { UNAVAILABLE } from "../data/entity/entity";
|
||||
import { showNumberSlider } from "../data/number";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -46,8 +47,6 @@ class StateCardNumber extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const range = this.stateObj.attributes.max - this.stateObj.attributes.min;
|
||||
|
||||
return html`
|
||||
<state-info
|
||||
.hass=${this.hass}
|
||||
@@ -55,8 +54,7 @@ class StateCardNumber extends LitElement {
|
||||
.inDialog=${this.inDialog}
|
||||
></state-info>
|
||||
${
|
||||
this.stateObj.attributes.mode === "slider" ||
|
||||
(this.stateObj.attributes.mode === "auto" && range <= 256)
|
||||
showNumberSlider(this.stateObj)
|
||||
? html`
|
||||
<div class="flex">
|
||||
<ha-slider
|
||||
|
||||
@@ -2510,8 +2510,8 @@
|
||||
"triggered": "Triggered {name}",
|
||||
"dismiss": "Dismiss",
|
||||
"no_matching_link_found": "No matching My link found for {path}",
|
||||
"new_version_available": "A new version of the frontend is available.",
|
||||
"reload": "Reload",
|
||||
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
|
||||
"update_now": "Update now",
|
||||
"theme_save_failed": "Unable to save theme settings to your user profile.",
|
||||
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
|
||||
"onboarding_survey": {
|
||||
@@ -8328,7 +8328,6 @@
|
||||
"status_online": "Online",
|
||||
"status_offline": "Offline",
|
||||
"my_network_title": "My network",
|
||||
"show_map": "[%key:ui::panel::config::bluetooth::show_map%]",
|
||||
"devices": "{count, plural,\n one {# device}\n other {# devices}\n}",
|
||||
"device_count": "{count, plural,\n one {# device}\n other {# devices}\n}",
|
||||
"entity_count": "{count, plural,\n one {# entity}\n other {# entities}\n}",
|
||||
@@ -8377,47 +8376,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"visualization": {
|
||||
"header": "Network visualization",
|
||||
"refresh_topology": "Refresh topology",
|
||||
"border_router": "Border router",
|
||||
"router": "Router",
|
||||
"end_device": "End device",
|
||||
"wifi_ap": "Wi-Fi access point",
|
||||
"offline": "Offline",
|
||||
"online": "Online",
|
||||
"unknown_device": "Unknown device",
|
||||
"unknown_devices": "Unknown devices",
|
||||
"node": "Node {node_id}",
|
||||
"node_id": "Node ID",
|
||||
"network": "Network",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"manufacturer": "Manufacturer",
|
||||
"model": "Model",
|
||||
"area": "Area",
|
||||
"last_seen": "Last seen",
|
||||
"via_route_table": "Learned from routing table",
|
||||
"empty": "No network topology data is available yet.",
|
||||
"not_supported": "The connected Matter server does not support network topology. Update your Matter server to use this feature.",
|
||||
"error_loading": "Failed to load the network topology: {error}",
|
||||
"strength": {
|
||||
"strong": "Strong",
|
||||
"medium": "Medium",
|
||||
"weak": "Weak",
|
||||
"none": "None"
|
||||
},
|
||||
"roles": {
|
||||
"leader": "Leader",
|
||||
"router": "Router",
|
||||
"reed": "Router-eligible end device",
|
||||
"end_device": "End device",
|
||||
"sleepy_end_device": "Sleepy end device",
|
||||
"unassigned": "Unassigned",
|
||||
"station": "Station",
|
||||
"ap": "Access point"
|
||||
}
|
||||
},
|
||||
"network_type": {
|
||||
"thread": "Thread",
|
||||
"wifi": "Wi-Fi",
|
||||
@@ -8735,6 +8693,10 @@
|
||||
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
|
||||
"invalid_host": "Enter a valid IP address.",
|
||||
"invalid_network": "Enter a valid IP address or network.",
|
||||
"running_default": "Your saved HTTP configuration could not be applied, so Home Assistant is running on the built-in default configuration.",
|
||||
"reverted_not_confirmed": "The last HTTP configuration change was not confirmed in time and was rolled back. Home Assistant is running on the previous configuration.",
|
||||
"reverted_failed": "The last HTTP configuration change could not be applied and was rolled back. Home Assistant is running on the previous configuration. Reason: {error}",
|
||||
"reverted_action": "Review the change",
|
||||
"save_confirm": {
|
||||
"title": "Restart required",
|
||||
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
|
||||
@@ -11290,6 +11252,7 @@
|
||||
"cards": {
|
||||
"demo": {
|
||||
"demo_by": "by {name}",
|
||||
"interactive_demo": "Interactive demo",
|
||||
"next_demo": "Next demo",
|
||||
"introduction": "Welcome home! You've reached the Home Assistant demo where we showcase the best UIs created by our community.",
|
||||
"learn_more": "Learn more about Home Assistant"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { showToast } from "./toast";
|
||||
|
||||
const UPDATE_DELAY = 60_000;
|
||||
const UPDATE_TOAST_ID = "frontend-update-available";
|
||||
|
||||
export const supportsServiceWorker = () =>
|
||||
"serviceWorker" in navigator &&
|
||||
(location.protocol === "https:" || location.hostname === "localhost");
|
||||
@@ -23,6 +26,92 @@ export const registerServiceWorker = async (
|
||||
return;
|
||||
}
|
||||
|
||||
let pendingWorker: ServiceWorker | undefined;
|
||||
let updateDeadline = 0;
|
||||
let updateInterval = 0;
|
||||
|
||||
const hideUpdateToast = () => {
|
||||
showToast(rootEl, {
|
||||
id: UPDATE_TOAST_ID,
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const clearUpdateCountdown = () => {
|
||||
clearInterval(updateInterval);
|
||||
};
|
||||
|
||||
const activateUpdate = () => {
|
||||
if (!pendingWorker) {
|
||||
return;
|
||||
}
|
||||
clearUpdateCountdown();
|
||||
hideUpdateToast();
|
||||
pendingWorker.postMessage({ type: "skipWaiting" });
|
||||
pendingWorker = undefined;
|
||||
};
|
||||
|
||||
const showUpdateToast = () => {
|
||||
const seconds = Math.ceil((updateDeadline - Date.now()) / 1000);
|
||||
if (seconds <= 0) {
|
||||
activateUpdate();
|
||||
return;
|
||||
}
|
||||
const announceSeconds =
|
||||
seconds > 40 ? 60 : seconds > 20 ? 40 : seconds > 5 ? 20 : 5;
|
||||
showToast(rootEl, {
|
||||
id: UPDATE_TOAST_ID,
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds },
|
||||
},
|
||||
announceMessage: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: announceSeconds },
|
||||
},
|
||||
action: {
|
||||
action: activateUpdate,
|
||||
primary: true,
|
||||
text: { translationKey: "ui.notification_toast.update_now" },
|
||||
},
|
||||
secondaryAction: {
|
||||
action: () => {
|
||||
pendingWorker = undefined;
|
||||
clearUpdateCountdown();
|
||||
},
|
||||
text: { translationKey: "ui.common.cancel" },
|
||||
},
|
||||
duration: -1,
|
||||
});
|
||||
};
|
||||
|
||||
const startUpdateCountdown = () => {
|
||||
updateDeadline = Date.now() + UPDATE_DELAY;
|
||||
showUpdateToast();
|
||||
updateInterval = window.setInterval(showUpdateToast, 1000);
|
||||
};
|
||||
|
||||
window.addEventListener("dirty-state-changed", () => {
|
||||
if (!pendingWorker) {
|
||||
return;
|
||||
}
|
||||
if (window.isDirtyState) {
|
||||
clearUpdateCountdown();
|
||||
hideUpdateToast();
|
||||
return;
|
||||
}
|
||||
startUpdateCountdown();
|
||||
});
|
||||
|
||||
const updateReady = (worker: ServiceWorker) => {
|
||||
clearUpdateCountdown();
|
||||
pendingWorker = worker;
|
||||
if (!window.isDirtyState) {
|
||||
startUpdateCountdown();
|
||||
}
|
||||
};
|
||||
|
||||
reg.addEventListener("updatefound", () => {
|
||||
const installingWorker = reg.installing;
|
||||
|
||||
@@ -37,22 +126,11 @@ export const registerServiceWorker = async (
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify users a new frontend is available.
|
||||
showToast(rootEl, {
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
},
|
||||
action: {
|
||||
// We tell the service worker to call skipWaiting, which activates
|
||||
// the new service worker. Above we listen for `controllerchange`
|
||||
// so we reload the page once a new service worker activates.
|
||||
action: () => installingWorker.postMessage({ type: "skipWaiting" }),
|
||||
text: { translationKey: "ui.notification_toast.reload" },
|
||||
},
|
||||
duration: -1,
|
||||
dismissable: false,
|
||||
});
|
||||
updateReady(installingWorker);
|
||||
});
|
||||
});
|
||||
|
||||
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||
updateReady(reg.waiting);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HaToast } from "../../src/components/ha-toast";
|
||||
import "../../src/components/ha-toast";
|
||||
|
||||
let toast: HaToast | undefined;
|
||||
|
||||
const mountToast = async (properties: Partial<HaToast> = {}) => {
|
||||
toast = document.createElement("ha-toast");
|
||||
Object.assign(toast, properties);
|
||||
document.body.append(toast);
|
||||
await toast.updateComplete;
|
||||
return toast;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
toast?.remove();
|
||||
toast = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const showToast = async (element: HaToast) => {
|
||||
vi.useFakeTimers();
|
||||
const shown = element.show();
|
||||
await element.updateComplete;
|
||||
Object.defineProperty(
|
||||
element.shadowRoot!.querySelector(".toast"),
|
||||
"getAnimations",
|
||||
{
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
}
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await shown;
|
||||
};
|
||||
|
||||
describe("ha-toast", () => {
|
||||
it("renders its message and bottom offset", async () => {
|
||||
const element = await mountToast({
|
||||
labelText: "Configuration saved",
|
||||
bottomOffset: 24,
|
||||
});
|
||||
|
||||
expect(element.shadowRoot?.querySelector(".message")?.textContent).toBe(
|
||||
"Configuration saved"
|
||||
);
|
||||
expect(
|
||||
(
|
||||
element.shadowRoot?.querySelector(".toast") as HTMLElement
|
||||
).style.getPropertyValue("--ha-toast-bottom-offset")
|
||||
).toBe("24px");
|
||||
});
|
||||
|
||||
it("renders assigned action and dismiss content", async () => {
|
||||
toast = document.createElement("ha-toast");
|
||||
const action = document.createElement("button");
|
||||
action.slot = "action";
|
||||
const dismiss = document.createElement("button");
|
||||
dismiss.slot = "dismiss";
|
||||
toast.append(action, dismiss);
|
||||
document.body.append(toast);
|
||||
await toast.updateComplete;
|
||||
toast.requestUpdate();
|
||||
await toast.updateComplete;
|
||||
|
||||
expect(
|
||||
toast.shadowRoot
|
||||
?.querySelector<HTMLSlotElement>('slot[name="action"]')
|
||||
?.assignedElements()
|
||||
).toEqual([action]);
|
||||
expect(
|
||||
toast.shadowRoot
|
||||
?.querySelector<HTMLSlotElement>('slot[name="dismiss"]')
|
||||
?.assignedElements()
|
||||
).toEqual([dismiss]);
|
||||
expect(
|
||||
toast.shadowRoot
|
||||
?.querySelector(".actions")
|
||||
?.classList.contains("has-action")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps changing visual text separate from live-region text", async () => {
|
||||
const element = await mountToast({
|
||||
labelText: "Updating in 59 seconds",
|
||||
announceText: "Updating in 60 seconds",
|
||||
});
|
||||
|
||||
const visibleMessage = element.shadowRoot!.querySelector(".message")!;
|
||||
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
|
||||
expect(visibleMessage.textContent).toBe("Updating in 59 seconds");
|
||||
expect(visibleMessage.closest('[role="status"]')).toBeNull();
|
||||
expect(liveRegion.textContent?.trim()).toBe("Updating in 60 seconds");
|
||||
expect(liveRegion.getAttribute("role")).toBe("status");
|
||||
expect(liveRegion.getAttribute("aria-atomic")).toBe("true");
|
||||
});
|
||||
|
||||
it("falls back to announcing the visible message", async () => {
|
||||
const element = await mountToast({ labelText: "Configuration saved" });
|
||||
|
||||
expect(
|
||||
element
|
||||
.shadowRoot!.querySelector(".assistive-message")!
|
||||
.textContent?.trim()
|
||||
).toBe("Configuration saved");
|
||||
});
|
||||
|
||||
it("activates its live region while shown", async () => {
|
||||
const element = await mountToast();
|
||||
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
|
||||
expect(liveRegion.getAttribute("aria-live")).toBe("off");
|
||||
|
||||
await showToast(element);
|
||||
|
||||
expect(liveRegion.getAttribute("aria-live")).toBe("polite");
|
||||
expect(
|
||||
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["action", "dismiss", "programmatic"] as const)(
|
||||
"reports a %s close reason",
|
||||
async (reason) => {
|
||||
const element = await mountToast({ timeoutMs: -1 });
|
||||
const listener = vi.fn();
|
||||
element.addEventListener("toast-closed", listener);
|
||||
await showToast(element);
|
||||
|
||||
await element.hide(reason);
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ reason });
|
||||
expect(
|
||||
element
|
||||
.shadowRoot!.querySelector(".toast")!
|
||||
.classList.contains("active")
|
||||
).toBe(false);
|
||||
expect(
|
||||
element
|
||||
.shadowRoot!.querySelector(".assistive-message")!
|
||||
.getAttribute("aria-live")
|
||||
).toBe("off");
|
||||
}
|
||||
);
|
||||
|
||||
it("closes with a timeout reason", async () => {
|
||||
const element = await mountToast({ timeoutMs: 1000 });
|
||||
const listener = vi.fn();
|
||||
element.addEventListener("toast-closed", listener);
|
||||
await showToast(element);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ reason: "timeout" });
|
||||
});
|
||||
|
||||
it("does not close automatically when the timeout is disabled", async () => {
|
||||
const element = await mountToast({ timeoutMs: -1 });
|
||||
const listener = vi.fn();
|
||||
element.addEventListener("toast-closed", listener);
|
||||
await showToast(element);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit a close event when already hidden", async () => {
|
||||
const element = await mountToast();
|
||||
const listener = vi.fn();
|
||||
element.addEventListener("toast-closed", listener);
|
||||
|
||||
await element.hide("dismiss");
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { showNumberSlider } from "../../src/data/number";
|
||||
|
||||
const makeStateObj = (attributes: Record<string, unknown>): HassEntity => ({
|
||||
entity_id: "number.test",
|
||||
state: "0",
|
||||
last_changed: "2024-01-01T00:00:00Z",
|
||||
last_updated: "2024-01-01T00:00:00Z",
|
||||
attributes: { min: 0, max: 100, step: 1, ...attributes },
|
||||
context: { id: "test", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
describe("showNumberSlider", () => {
|
||||
it("shows a slider in slider mode regardless of step count", () => {
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "slider", min: 0, max: 100000, step: 1 })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a slider in auto mode when the step count is at or below the threshold", () => {
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "auto", min: 0, max: 256, step: 1 })
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "auto", min: 0, max: 1000, step: 10 })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a box in auto mode when the step count is above the threshold", () => {
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "auto", min: 0, max: 257, step: 1 })
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
showNumberSlider(
|
||||
makeStateObj({ mode: "auto", min: 0, max: 100, step: 0.1 })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a box for box mode", () => {
|
||||
expect(showNumberSlider(makeStateObj({ mode: "box" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import "../../src/managers/notification-manager";
|
||||
|
||||
vi.mock("../../src/components/ha-button", () => {
|
||||
customElements.define("ha-button", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
const localize = (key: string, args?: Record<string, string | number>) =>
|
||||
args ? `${key}:${JSON.stringify(args)}` : key;
|
||||
|
||||
@customElement("test-notification-lifecycle-host")
|
||||
class TestNotificationLifecycleHost extends LitElement {
|
||||
@property({ attribute: false }) public hass = {
|
||||
localize,
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
protected render() {
|
||||
return html`<notification-manager
|
||||
.hass=${this.hass}
|
||||
></notification-manager>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-notification-lifecycle-host": TestNotificationLifecycleHost;
|
||||
}
|
||||
}
|
||||
|
||||
let host: TestNotificationLifecycleHost | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("notification toast lifecycle", () => {
|
||||
it("updates a visible toast and closes it after its primary action", async () => {
|
||||
vi.useFakeTimers();
|
||||
host = document.createElement("test-notification-lifecycle-host");
|
||||
document.body.append(host);
|
||||
await host.updateComplete;
|
||||
const manager = host.shadowRoot!.querySelector("notification-manager")!;
|
||||
const action = vi.fn();
|
||||
|
||||
const firstShow = manager.showDialog({
|
||||
id: "frontend-update-available",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
announceMessage: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
action: { action, primary: true, text: "Update now" },
|
||||
duration: -1,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await manager.updateComplete;
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
Object.defineProperty(
|
||||
toast.shadowRoot!.querySelector(".toast"),
|
||||
"getAnimations",
|
||||
{
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
}
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await firstShow;
|
||||
|
||||
expect(toast.shadowRoot!.querySelector(".toast")!.classList).toContain(
|
||||
"visible"
|
||||
);
|
||||
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
|
||||
'"seconds":60'
|
||||
);
|
||||
expect(
|
||||
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
|
||||
).toContain('"seconds":60');
|
||||
|
||||
await manager.showDialog({
|
||||
id: "frontend-update-available",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 59 },
|
||||
},
|
||||
announceMessage: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
action: { action, primary: true, text: "Update now" },
|
||||
duration: -1,
|
||||
});
|
||||
|
||||
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
|
||||
'"seconds":59'
|
||||
);
|
||||
expect(
|
||||
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
|
||||
).toContain('"seconds":60');
|
||||
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
toast.addEventListener("toast-closed", () => resolve(), { once: true });
|
||||
});
|
||||
manager.shadowRoot!.querySelector<HTMLElement>("ha-button")!.click();
|
||||
await closed;
|
||||
await manager.updateComplete;
|
||||
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import "../../src/managers/notification-manager";
|
||||
|
||||
const localize = vi.fn((key: string, args?: Record<string, string | number>) =>
|
||||
args ? `${key}:${JSON.stringify(args)}` : key
|
||||
);
|
||||
|
||||
vi.mock("../../src/components/ha-button", () => {
|
||||
customElements.define("ha-button", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/ha-icon-button", () => {
|
||||
customElements.define("ha-icon-button", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/ha-toast", () => {
|
||||
customElements.define(
|
||||
"ha-toast",
|
||||
class extends HTMLElement {
|
||||
public show = vi.fn();
|
||||
|
||||
public hide = vi.fn();
|
||||
}
|
||||
);
|
||||
return {};
|
||||
});
|
||||
|
||||
@customElement("test-notification-manager-host")
|
||||
class TestNotificationManagerHost extends LitElement {
|
||||
@property({ attribute: false }) public hass = {
|
||||
localize,
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
protected render() {
|
||||
return html`<notification-manager
|
||||
.hass=${this.hass}
|
||||
></notification-manager>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-notification-manager-host": TestNotificationManagerHost;
|
||||
}
|
||||
}
|
||||
|
||||
let host: TestNotificationManagerHost | undefined;
|
||||
|
||||
const mountManager = async () => {
|
||||
host = document.createElement(
|
||||
"test-notification-manager-host"
|
||||
) as TestNotificationManagerHost;
|
||||
document.body.append(host);
|
||||
await host.updateComplete;
|
||||
return host.shadowRoot!.querySelector("notification-manager")!;
|
||||
};
|
||||
|
||||
const deferred = () => {
|
||||
let resolve: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve: resolve! };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("notification-manager", () => {
|
||||
it("shows a message with the default duration", async () => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({ message: "Configuration saved" });
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
expect(toast.labelText).toBe("Configuration saved");
|
||||
expect(toast.timeoutMs).toBe(4000);
|
||||
expect(toast.bottomOffset).toBe(0);
|
||||
expect(toast.show).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders a dismiss button and closes with the dismiss reason", async () => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({
|
||||
message: "Connection lost",
|
||||
dismissable: true,
|
||||
duration: -1,
|
||||
bottomOffset: 16,
|
||||
});
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
expect(toast.timeoutMs).toBe(-1);
|
||||
expect(toast.bottomOffset).toBe(16);
|
||||
|
||||
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
|
||||
expect(toast.hide).toHaveBeenCalledWith("dismiss");
|
||||
});
|
||||
|
||||
it("clears the current notification when duration is zero", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ message: "First message", duration: -1 });
|
||||
|
||||
await manager.showDialog({ message: "", duration: 0 });
|
||||
await manager.updateComplete;
|
||||
|
||||
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ duration: 1, expected: 4000 },
|
||||
{ duration: 4000, expected: 4000 },
|
||||
{ duration: 4001, expected: 4001 },
|
||||
{ duration: -1, expected: -1 },
|
||||
])(
|
||||
"normalizes a $duration ms duration to $expected ms",
|
||||
async ({ duration, expected }) => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({ message: "Message", duration });
|
||||
|
||||
expect(manager.shadowRoot!.querySelector("ha-toast")!.timeoutMs).toBe(
|
||||
expected
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("localizes visible and assistive messages with numeric arguments", async () => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 59 },
|
||||
},
|
||||
announceMessage: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
});
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
expect(toast.labelText).toContain('"seconds":59');
|
||||
expect(toast.announceText).toContain('"seconds":60');
|
||||
expect(localize).toHaveBeenCalledWith(
|
||||
"ui.notification_toast.new_version_available",
|
||||
{ seconds: 59 }
|
||||
);
|
||||
expect(localize).toHaveBeenCalledWith(
|
||||
"ui.notification_toast.new_version_available",
|
||||
{ seconds: 60 }
|
||||
);
|
||||
});
|
||||
|
||||
it("renders and invokes primary and secondary actions", async () => {
|
||||
const manager = await mountManager();
|
||||
const primary = vi.fn();
|
||||
const secondary = vi.fn();
|
||||
|
||||
await manager.showDialog({
|
||||
message: "Update available",
|
||||
action: { action: primary, primary: true, text: "Update now" },
|
||||
secondaryAction: { action: secondary, text: "Cancel" },
|
||||
duration: -1,
|
||||
});
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
const buttons = manager.shadowRoot!.querySelectorAll("ha-button");
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons[0].textContent?.trim()).toBe("Cancel");
|
||||
expect(buttons[0].getAttribute("appearance")).toBe("plain");
|
||||
expect(buttons[1].textContent?.trim()).toBe("Update now");
|
||||
expect(buttons[1].getAttribute("appearance")).toBe("filled");
|
||||
|
||||
buttons[0].click();
|
||||
expect(toast.hide).toHaveBeenLastCalledWith("action");
|
||||
expect(secondary).toHaveBeenCalledOnce();
|
||||
expect(primary).not.toHaveBeenCalled();
|
||||
|
||||
buttons[1].click();
|
||||
expect(toast.hide).toHaveBeenLastCalledWith("action");
|
||||
expect(primary).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a same-ID toast visible while replacing its content", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ id: "status", message: "First" });
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
vi.mocked(toast.hide).mockClear();
|
||||
|
||||
await manager.showDialog({ id: "status", message: "Second" });
|
||||
|
||||
expect(toast.hide).not.toHaveBeenCalled();
|
||||
expect(toast.labelText).toBe("Second");
|
||||
});
|
||||
|
||||
it("hides a toast before replacing it with a different ID", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ id: "first", message: "First" });
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
vi.mocked(toast.hide).mockClear();
|
||||
|
||||
await manager.showDialog({ id: "second", message: "Second" });
|
||||
|
||||
expect(toast.hide).toHaveBeenCalledOnce();
|
||||
expect(toast.labelText).toBe("Second");
|
||||
});
|
||||
|
||||
it("ignores a stale replacement after a newer notification starts", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ id: "first", message: "First" });
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
const delayedHide = deferred();
|
||||
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
|
||||
|
||||
const staleShow = manager.showDialog({ id: "second", message: "Second" });
|
||||
await manager.showDialog({ id: "third", message: "Third" });
|
||||
delayedHide.resolve();
|
||||
await staleShow;
|
||||
|
||||
expect(toast.labelText).toBe("Third");
|
||||
});
|
||||
|
||||
it("clears rendered state after the toast closes", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ message: "Message" });
|
||||
manager.shadowRoot!.querySelector("ha-toast")!.dispatchEvent(
|
||||
new CustomEvent("toast-closed", {
|
||||
detail: { reason: "programmatic" },
|
||||
})
|
||||
);
|
||||
|
||||
await manager.updateComplete;
|
||||
|
||||
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { LitElement } from "lit";
|
||||
import { state } from "lit/decorators";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
dirtyStateContext,
|
||||
type DirtyStateContext,
|
||||
} from "../../src/data/context/dirty-state";
|
||||
import type { CompareStrategy } from "../../src/mixins/dirty-state-provider-mixin";
|
||||
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
|
||||
|
||||
interface TestState {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
options?: { mode: string };
|
||||
}
|
||||
|
||||
class TestDirtyStateConsumer extends LitElement {
|
||||
@consume({ context: dirtyStateContext, subscribe: true })
|
||||
@state()
|
||||
public dirtyState?: DirtyStateContext;
|
||||
}
|
||||
|
||||
customElements.define("test-dirty-state-consumer", TestDirtyStateConsumer);
|
||||
|
||||
class TestDirtyStateProvider extends DirtyStateProviderMixin<TestState>()(
|
||||
LitElement
|
||||
) {
|
||||
public initialize(
|
||||
strategy: CompareStrategy<TestState>,
|
||||
value?: TestState,
|
||||
normalize?: (value: TestState) => TestState
|
||||
) {
|
||||
this._initDirtyTracking(strategy, value, normalize);
|
||||
}
|
||||
|
||||
public setValue(value: TestState) {
|
||||
this._updateDirtyState(value);
|
||||
}
|
||||
|
||||
public markClean() {
|
||||
this._markDirtyStateClean();
|
||||
}
|
||||
|
||||
public discardChanges() {
|
||||
this._discardDirtyStateChanges();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-dirty-state-provider", TestDirtyStateProvider);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-dirty-state-consumer": TestDirtyStateConsumer;
|
||||
"test-dirty-state-provider": TestDirtyStateProvider;
|
||||
}
|
||||
}
|
||||
|
||||
let provider: TestDirtyStateProvider | undefined;
|
||||
|
||||
const mountProvider = async () => {
|
||||
provider = document.createElement(
|
||||
"test-dirty-state-provider"
|
||||
) as TestDirtyStateProvider;
|
||||
document.body.append(provider);
|
||||
await provider.updateComplete;
|
||||
return provider;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll("test-dirty-state-provider").forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
provider = undefined;
|
||||
window.isDirtyState = false;
|
||||
});
|
||||
|
||||
describe("DirtyStateProviderMixin", () => {
|
||||
it("tracks changes against an initial state", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
|
||||
element.setValue({ name: "Kitchen light", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
|
||||
element.setValue({ name: "Kitchen", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("marks the current state as clean", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "Kitchen light", enabled: false });
|
||||
await element.updateComplete;
|
||||
|
||||
element.markClean();
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
|
||||
element.setValue({ name: "Kitchen", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("discards changes back to the initial state", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "Kitchen light", enabled: true });
|
||||
await element.updateComplete;
|
||||
|
||||
element.discardChanges();
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
expect(element.isEffectiveDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("uses deep comparison for nested values", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "deep" },
|
||||
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
|
||||
);
|
||||
element.setValue({
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "auto" },
|
||||
});
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
|
||||
element.setValue({
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "manual" },
|
||||
});
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("uses reference comparison for nested values with a shallow strategy", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
|
||||
);
|
||||
element.setValue({
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "auto" },
|
||||
});
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("supports a custom comparison strategy", async () => {
|
||||
const element = await mountProvider();
|
||||
const compare = vi.fn(
|
||||
(initial: TestState, current: TestState) =>
|
||||
initial.name.toLowerCase() === current.name.toLowerCase()
|
||||
);
|
||||
element.initialize(
|
||||
{ type: "custom", compare },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "KITCHEN", enabled: true });
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
expect(compare).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the deep baseline isolated from initial state mutations", async () => {
|
||||
const element = await mountProvider();
|
||||
const initial = {
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "auto" },
|
||||
};
|
||||
element.initialize({ type: "deep" }, initial);
|
||||
initial.options.mode = "manual";
|
||||
element.setValue({
|
||||
name: "Kitchen",
|
||||
enabled: false,
|
||||
options: { mode: "auto" },
|
||||
});
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the first deferred value as its baseline", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize({ type: "shallow" });
|
||||
element.setValue({ name: "Kitchen", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
|
||||
element.setValue({ name: "Bedroom", enabled: false });
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks raw and effective dirty state separately", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false },
|
||||
(value) => ({ ...value, enabled: false })
|
||||
);
|
||||
element.setValue({ name: "Kitchen", enabled: true });
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
expect(element.isEffectiveDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes dirty and clean transitions with event details", async () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("dirty-state-changed", listener);
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
await element.updateComplete;
|
||||
listener.mockClear();
|
||||
|
||||
element.setValue({ name: "Bedroom", enabled: false });
|
||||
await element.updateComplete;
|
||||
element.setValue({ name: "Kitchen", enabled: false });
|
||||
await element.updateComplete;
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: true });
|
||||
expect(listener.mock.calls[1][0].detail).toEqual({ isDirty: false });
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
window.removeEventListener("dirty-state-changed", listener);
|
||||
});
|
||||
|
||||
it("clears published dirty state when disconnected", async () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("dirty-state-changed", listener);
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "Bedroom", enabled: false });
|
||||
await element.updateComplete;
|
||||
listener.mockClear();
|
||||
|
||||
element.remove();
|
||||
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: false });
|
||||
window.removeEventListener("dirty-state-changed", listener);
|
||||
});
|
||||
|
||||
it("hands global dirty state to the next provider after disconnecting", async () => {
|
||||
const firstProvider = await mountProvider();
|
||||
firstProvider.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
firstProvider.setValue({ name: "Bedroom", enabled: false });
|
||||
await firstProvider.updateComplete;
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
|
||||
firstProvider.remove();
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
|
||||
const nextProvider = await mountProvider();
|
||||
nextProvider.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Hallway", enabled: false }
|
||||
);
|
||||
nextProvider.setValue({ name: "Hallway", enabled: true });
|
||||
await nextProvider.updateComplete;
|
||||
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("stays globally dirty while any connected provider is dirty", async () => {
|
||||
const dirtyProvider = await mountProvider();
|
||||
dirtyProvider.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
dirtyProvider.setValue({ name: "Bedroom", enabled: false });
|
||||
await dirtyProvider.updateComplete;
|
||||
|
||||
const cleanProvider = document.createElement("test-dirty-state-provider");
|
||||
document.body.append(cleanProvider);
|
||||
cleanProvider.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Hallway", enabled: false }
|
||||
);
|
||||
await cleanProvider.updateComplete;
|
||||
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
|
||||
cleanProvider.remove();
|
||||
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
|
||||
dirtyProvider.setValue({ name: "Kitchen", enabled: false });
|
||||
await dirtyProvider.updateComplete;
|
||||
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("restores a provider's dirty state after reconnecting", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
element.setValue({ name: "Bedroom", enabled: false });
|
||||
await element.updateComplete;
|
||||
|
||||
element.remove();
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
|
||||
document.body.append(element);
|
||||
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks independent context slices and marks all of them clean", async () => {
|
||||
const element = await mountProvider();
|
||||
element.initialize({ type: "shallow" });
|
||||
const consumer = document.createElement("test-dirty-state-consumer");
|
||||
element.append(consumer);
|
||||
await consumer.updateComplete;
|
||||
const setState = (value: TestState, key: "first" | "second") => {
|
||||
Reflect.apply(consumer.dirtyState!.setState, undefined, [value, key]);
|
||||
};
|
||||
|
||||
setState({ name: "Kitchen", enabled: false }, "first");
|
||||
setState({ name: "Bedroom", enabled: false }, "second");
|
||||
await element.updateComplete;
|
||||
setState({ name: "Kitchen light", enabled: false }, "first");
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(true);
|
||||
|
||||
consumer.dirtyState!.markClean();
|
||||
await element.updateComplete;
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,433 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
MatterNetworkTopologyNode,
|
||||
} from "../../../../../src/data/matter";
|
||||
import {
|
||||
createMatterNetworkChartData,
|
||||
getTopologyNodeCategory,
|
||||
getTopologyNodeName,
|
||||
strengthToScale,
|
||||
} from "../../../../../src/panels/config/integrations/integration-panels/matter/matter-network-data";
|
||||
import type { HomeAssistant } from "../../../../../src/types";
|
||||
|
||||
const mockHass = (
|
||||
devices: Record<string, Partial<HomeAssistant["devices"][string]>> = {},
|
||||
areas: Record<string, Partial<HomeAssistant["areas"][string]>> = {}
|
||||
): HomeAssistant =>
|
||||
({
|
||||
localize: (key: string) => key.split(".").pop(),
|
||||
devices,
|
||||
areas,
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
const node = (
|
||||
overrides: Partial<MatterNetworkTopologyNode> & { id: string }
|
||||
): MatterNetworkTopologyNode => ({
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const connection = (
|
||||
overrides: Partial<MatterNetworkTopologyConnection> & {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
): MatterNetworkTopologyConnection => ({
|
||||
network: "thread",
|
||||
strength: "strong",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const topology = (
|
||||
nodes: MatterNetworkTopologyNode[],
|
||||
connections: MatterNetworkTopologyConnection[] = []
|
||||
): MatterNetworkTopology => ({
|
||||
collected_at: 1767888000000,
|
||||
nodes,
|
||||
connections,
|
||||
});
|
||||
|
||||
const element = document.createElement("div");
|
||||
document.body.appendChild(element);
|
||||
|
||||
describe("strengthToScale", () => {
|
||||
it("never returns a falsy value so the graph arrow stays suppressed", () => {
|
||||
expect(strengthToScale("strong")).toBe(4);
|
||||
expect(strengthToScale("medium")).toBe(3);
|
||||
expect(strengthToScale("weak")).toBe(2);
|
||||
expect(strengthToScale("none")).toBe(1);
|
||||
expect(strengthToScale(undefined)).toBe(1);
|
||||
expect(strengthToScale(null)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopologyNodeCategory", () => {
|
||||
it("maps kinds and roles to categories", () => {
|
||||
// category 0 is reserved for the synthesized Home Assistant root node
|
||||
expect(
|
||||
getTopologyNodeCategory(node({ id: "br", kind: "border_router" }))
|
||||
).toBe(1);
|
||||
expect(getTopologyNodeCategory(node({ id: "1", role: "leader" }))).toBe(2);
|
||||
expect(getTopologyNodeCategory(node({ id: "2", role: "router" }))).toBe(2);
|
||||
expect(getTopologyNodeCategory(node({ id: "3", role: "reed" }))).toBe(2);
|
||||
expect(getTopologyNodeCategory(node({ id: "4", role: "end_device" }))).toBe(
|
||||
3
|
||||
);
|
||||
expect(
|
||||
getTopologyNodeCategory(node({ id: "5", role: "sleepy_end_device" }))
|
||||
).toBe(3);
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "6", network_type: "wifi", role: "station" })
|
||||
)
|
||||
).toBe(3);
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "ap_112233445566", kind: "wifi_ap", network_type: "wifi" })
|
||||
)
|
||||
).toBe(4);
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "7", role: "router", available: false })
|
||||
)
|
||||
).toBe(5);
|
||||
expect(
|
||||
getTopologyNodeCategory(node({ id: "unknown_1", kind: "thread_unknown" }))
|
||||
).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopologyNodeName", () => {
|
||||
it("prefers the HA device name", () => {
|
||||
const hass = mockHass({
|
||||
dev1: { name_by_user: "Living room plug", name: "Plug" },
|
||||
});
|
||||
expect(
|
||||
getTopologyNodeName(
|
||||
node({ id: "1", node_id: 1, ha_device_id: "dev1" }),
|
||||
hass
|
||||
)
|
||||
).toBe("Living room plug");
|
||||
});
|
||||
|
||||
it("falls back to wire metadata for external nodes", () => {
|
||||
const hass = mockHass();
|
||||
expect(
|
||||
getTopologyNodeName(
|
||||
node({ id: "br_1", kind: "border_router", vendor_name: "Apple" }),
|
||||
hass
|
||||
)
|
||||
).toBe("Apple");
|
||||
expect(
|
||||
getTopologyNodeName(
|
||||
node({
|
||||
id: "ap_112233445566",
|
||||
kind: "wifi_ap",
|
||||
network_type: "wifi",
|
||||
network_name: "MyWiFi",
|
||||
}),
|
||||
hass
|
||||
)
|
||||
).toBe("MyWiFi");
|
||||
expect(
|
||||
getTopologyNodeName(
|
||||
node({ id: "unknown_1", kind: "thread_unknown" }),
|
||||
hass
|
||||
)
|
||||
).toBe("unknown_device");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMatterNetworkChartData", () => {
|
||||
it("maps a thread mesh with a border router", () => {
|
||||
const hass = mockHass(
|
||||
{ dev1: { name: "Leader plug", area_id: "living" } },
|
||||
{ living: { name: "Living room" } }
|
||||
);
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({ id: "1", node_id: 1, ha_device_id: "dev1", role: "leader" }),
|
||||
node({ id: "2", node_id: 2, role: "end_device", available: true }),
|
||||
node({ id: "br_1", kind: "border_router", vendor_name: "Apple" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "1",
|
||||
target: "2",
|
||||
strength: "medium",
|
||||
source_to_target: { strength: "medium", lqi: 2 },
|
||||
target_to_source: { strength: "medium", lqi: 2 },
|
||||
}),
|
||||
connection({
|
||||
source: "1",
|
||||
target: "br_1",
|
||||
source_to_target: { strength: "strong", lqi: 3 },
|
||||
target_to_source: { strength: "strong", lqi: 3 },
|
||||
}),
|
||||
]
|
||||
),
|
||||
hass,
|
||||
element
|
||||
);
|
||||
|
||||
expect(data.categories).toHaveLength(7);
|
||||
// Home Assistant root + the 3 topology nodes
|
||||
expect(data.nodes).toHaveLength(4);
|
||||
|
||||
const ha = data.nodes[0];
|
||||
expect(ha.id).toBe("ha");
|
||||
expect(ha.category).toBe(0);
|
||||
expect(ha.fixed).toBe(true);
|
||||
expect(ha.polarDistance).toBe(0);
|
||||
|
||||
const leader = data.nodes.find((n) => n.id === "1")!;
|
||||
expect(leader.name).toBe("Leader plug");
|
||||
expect(leader.context).toBe("Living room");
|
||||
expect(leader.category).toBe(2);
|
||||
expect(leader.itemStyle?.borderWidth).toBe(2);
|
||||
|
||||
const endDevice = data.nodes.find((n) => n.id === "2")!;
|
||||
expect(endDevice.category).toBe(3);
|
||||
expect(endDevice.itemStyle?.borderWidth).toBeUndefined();
|
||||
|
||||
const borderRouter = data.nodes.find((n) => n.id === "br_1")!;
|
||||
expect(borderRouter.category).toBe(1);
|
||||
expect(borderRouter.symbol).toBe("roundRect");
|
||||
|
||||
const meshLink = data.links.find(
|
||||
(l) => l.source === "1" && l.target === "2"
|
||||
)!;
|
||||
expect(meshLink.value).toBe(3);
|
||||
expect(meshLink.reverseValue).toBe(3);
|
||||
expect(meshLink.lineStyle?.type).toBe("solid");
|
||||
|
||||
// HA anchors to the border router (the mesh's infrastructure), not to
|
||||
// the individual routers hanging off it
|
||||
const haLink = data.links.find((l) => l.source === "ha")!;
|
||||
expect(haLink.target).toBe("br_1");
|
||||
expect(haLink.symbol).toBe("none");
|
||||
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("marks asymmetric links dashed and keeps one-way arrows", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "2", node_id: 2, role: "router" }),
|
||||
node({ id: "3", node_id: 3, role: "router" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "1",
|
||||
target: "2",
|
||||
source_to_target: { strength: "strong", lqi: 3 },
|
||||
target_to_source: { strength: "weak", lqi: 1 },
|
||||
}),
|
||||
// only observed from node 3's side: 3 → 2
|
||||
connection({
|
||||
source: "2",
|
||||
target: "3",
|
||||
strength: "medium",
|
||||
target_to_source: { strength: "medium", lqi: 2 },
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const asymmetric = data.links.find(
|
||||
(l) => l.source === "1" && l.target === "2"
|
||||
)!;
|
||||
expect(asymmetric.lineStyle?.type).toBe("dashed");
|
||||
expect(asymmetric.value).toBe(4);
|
||||
expect(asymmetric.reverseValue).toBe(2);
|
||||
|
||||
// one-way link is flipped so the arrow points the observed direction
|
||||
const oneWay = data.links.find(
|
||||
(l) => l.source === "3" && l.target === "2"
|
||||
)!;
|
||||
expect(oneWay.reverseValue).toBeUndefined();
|
||||
expect(oneWay.symbolSize).toBeGreaterThan(0);
|
||||
expect(oneWay.lineStyle?.type).toBe("dashed");
|
||||
});
|
||||
|
||||
it("suppresses direction arrows on route-table edges", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "2", node_id: 2, role: "router" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "1",
|
||||
target: "2",
|
||||
strength: "none",
|
||||
via_route_table: true,
|
||||
path_cost: 1,
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const link = data.links[0];
|
||||
expect(link.value).toBe(1);
|
||||
expect(link.reverseValue).toBe(1);
|
||||
expect(link.lineStyle?.type).toBe("dotted");
|
||||
});
|
||||
|
||||
it("keeps every node attached to the force layout", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "2", node_id: 2, role: "router" }),
|
||||
node({ id: "3", node_id: 3, role: "end_device" }),
|
||||
node({ id: "br_1", kind: "border_router" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "1",
|
||||
target: "br_1",
|
||||
source_to_target: { strength: "strong", lqi: 3 },
|
||||
target_to_source: { strength: "strong", lqi: 3 },
|
||||
}),
|
||||
connection({
|
||||
source: "1",
|
||||
target: "2",
|
||||
strength: "weak",
|
||||
source_to_target: { strength: "weak", lqi: 1 },
|
||||
target_to_source: { strength: "weak", lqi: 1 },
|
||||
}),
|
||||
connection({
|
||||
source: "2",
|
||||
target: "3",
|
||||
strength: "weak",
|
||||
source_to_target: { strength: "weak", lqi: 1 },
|
||||
target_to_source: { strength: "weak", lqi: 1 },
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
// hub link stays active, and every node has at least one active link
|
||||
const hubLink = data.links.find((l) => l.target === "br_1")!;
|
||||
expect(hubLink.ignoreForceLayout).toBe(false);
|
||||
data.nodes.forEach((n) => {
|
||||
const nodeLinks = data.links.filter(
|
||||
(l) => l.source === n.id || l.target === n.id
|
||||
);
|
||||
expect(
|
||||
nodeLinks.some((l) => !l.ignoreForceLayout),
|
||||
`node ${n.id} has no active link`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates minimal nodes and skips connections to unknown nodes", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[node({ id: "1" })],
|
||||
[connection({ source: "1", target: "missing" })]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
// Home Assistant root + the single topology node
|
||||
expect(data.nodes).toHaveLength(2);
|
||||
// the bogus connection is skipped; the lone node is anchored to HA
|
||||
expect(data.links).toHaveLength(1);
|
||||
expect(data.links[0].source).toBe("ha");
|
||||
expect(data.links[0].target).toBe("1");
|
||||
});
|
||||
|
||||
it("anchors unconnected routers directly to Home Assistant", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "2", node_id: 2, role: "router" }),
|
||||
]),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const haTargets = data.links
|
||||
.filter((l) => l.source === "ha")
|
||||
.map((l) => l.target)
|
||||
.sort();
|
||||
expect(haTargets).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("routes HA through the Wi-Fi access point, not the stations", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({
|
||||
id: "ap_112233445566",
|
||||
kind: "wifi_ap",
|
||||
network_type: "wifi",
|
||||
}),
|
||||
node({ id: "7", node_id: 7, network_type: "wifi", role: "station" }),
|
||||
node({ id: "8", node_id: 8, network_type: "wifi", role: "station" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "7",
|
||||
target: "ap_112233445566",
|
||||
network: "wifi",
|
||||
source_to_target: { strength: "strong", rssi: -55 },
|
||||
}),
|
||||
connection({
|
||||
source: "8",
|
||||
target: "ap_112233445566",
|
||||
network: "wifi",
|
||||
source_to_target: { strength: "medium", rssi: -70 },
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const haTargets = data.links
|
||||
.filter((l) => l.source === "ha")
|
||||
.map((l) => l.target);
|
||||
expect(haTargets).toEqual(["ap_112233445566"]);
|
||||
});
|
||||
|
||||
it("adds the network name to the context when there are multiple networks", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "1",
|
||||
node_id: 1,
|
||||
ext_pan_id: "AAA",
|
||||
network_name: "NetA",
|
||||
}),
|
||||
node({
|
||||
id: "2",
|
||||
node_id: 2,
|
||||
ext_pan_id: "BBB",
|
||||
network_name: "NetB",
|
||||
}),
|
||||
]),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
expect(data.nodes.find((n) => n.id === "1")!.context).toBe("NetA");
|
||||
expect(data.nodes.find((n) => n.id === "2")!.context).toBe("NetB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
BuildSankeyDeviceNodesOptions,
|
||||
SankeyDeviceNode,
|
||||
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
getSankeyDeviceSections,
|
||||
groupSankeyDevicesByFloorAndArea,
|
||||
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
|
||||
import type { Node } from "../../../../../../src/components/chart/ha-sankey-chart";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../../../src/data/energy";
|
||||
import type { HomeAssistant } from "../../../../../../src/types";
|
||||
import { createMockComputedStyle } from "../../../../../fixtures/computed-style";
|
||||
import {
|
||||
createMockEntityState,
|
||||
createMockHass,
|
||||
mockLocalize,
|
||||
} from "../../../../../fixtures/hass";
|
||||
|
||||
const computedStyle = createMockComputedStyle();
|
||||
|
||||
const devices = (
|
||||
...items: DeviceConsumptionEnergyPreference[]
|
||||
): DeviceConsumptionEnergyPreference[] => items;
|
||||
|
||||
// Cumulative-style option factory (id === stat_consumption, always clickable).
|
||||
const cumulativeOpts = (
|
||||
overrides: { values: Record<string, number> } & Pick<
|
||||
BuildSankeyDeviceNodesOptions,
|
||||
"devices"
|
||||
> &
|
||||
Partial<BuildSankeyDeviceNodesOptions>
|
||||
): BuildSankeyDeviceNodesOptions => {
|
||||
const { values, ...rest } = overrides;
|
||||
return {
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: 0.01,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: 0,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: (id) => values[id] ?? 0,
|
||||
getLabel: (id, name) => name || id,
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent: () => undefined,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
describe("getSankeyDeviceSections", () => {
|
||||
it("returns a single section when there are no parent links", () => {
|
||||
const nodes: Node[] = [
|
||||
{ id: "a", value: 1, index: 4 },
|
||||
{ id: "b", value: 2, index: 4 },
|
||||
];
|
||||
expect(getSankeyDeviceSections({}, nodes)).toEqual([nodes]);
|
||||
});
|
||||
|
||||
it("splits parents and children into ordered sections", () => {
|
||||
const parent: Node = { id: "parent", value: 10, index: 4 };
|
||||
const child: Node = { id: "child", value: 4, index: 4 };
|
||||
const sections = getSankeyDeviceSections({ child: "parent" }, [
|
||||
parent,
|
||||
child,
|
||||
]);
|
||||
expect(sections).toEqual([[parent], [child]]);
|
||||
});
|
||||
|
||||
it("recurses through multiple parent levels", () => {
|
||||
const grandparent: Node = { id: "grandparent", value: 10, index: 4 };
|
||||
const parent: Node = { id: "parent", value: 6, index: 4 };
|
||||
const child: Node = { id: "child", value: 4, index: 4 };
|
||||
const sections = getSankeyDeviceSections(
|
||||
{ parent: "grandparent", child: "parent" },
|
||||
[grandparent, parent, child]
|
||||
);
|
||||
expect(sections).toEqual([[grandparent], [parent], [child]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSankeyDeviceNodes", () => {
|
||||
it("renders top-level devices and subtracts them from untracked", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices({ stat_consumption: "a" }, { stat_consumption: "b" }),
|
||||
values: { a: 10, b: 5 },
|
||||
initialUntracked: 15,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual(["a", "b"]);
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
// top-level devices carry no link here (linked in the layout step)
|
||||
expect(result.links).toEqual([]);
|
||||
expect(result.parentLinks).toEqual({});
|
||||
});
|
||||
|
||||
it("shows a lone sub-threshold device directly (no Other node)", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a" },
|
||||
{ stat_consumption: "small" }
|
||||
),
|
||||
values: { a: 10, small: 0.005 },
|
||||
initialUntracked: 10.005,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id).sort()).toEqual(["a", "small"]);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
|
||||
false
|
||||
);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("groups multiple sub-threshold devices into an Other node", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "s1" },
|
||||
{ stat_consumption: "s2" }
|
||||
),
|
||||
values: { s1: 0.003, s2: 0.004 },
|
||||
initialUntracked: 0.007,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes).toHaveLength(1);
|
||||
const other = result.deviceNodes[0];
|
||||
expect(other.id).toBe("other_home");
|
||||
expect(other.value).toBeCloseTo(0.007, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("ceils the Other node value only when ceilOtherValue is set, but always subtracts the raw total", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "s1" },
|
||||
{ stat_consumption: "s2" }
|
||||
),
|
||||
values: { s1: 0.3, s2: 0.4 },
|
||||
minThreshold: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: 0.7,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes[0];
|
||||
expect(other.value).toBe(1); // Math.ceil(0.7)
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10); // raw 0.7 subtracted
|
||||
});
|
||||
|
||||
it("skips devices whose id resolves to undefined (instantaneous with no stat_rate)", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a", stat_rate: undefined },
|
||||
{ stat_consumption: "b", stat_rate: "sensor.b_rate" }
|
||||
),
|
||||
values: { "sensor.b_rate": 10 },
|
||||
initialUntracked: 10,
|
||||
getId: (device) => device.stat_rate,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual(["sensor.b_rate"]);
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
|
||||
it("adds a per-parent untracked residual above the floor", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "child", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 10, child: 4 },
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
expect(result.parentLinks.child).toBe("parent");
|
||||
const residual = result.deviceNodes.find((n) =>
|
||||
n.id.startsWith("untracked_")
|
||||
);
|
||||
expect(residual?.id).toBe("untracked_parent");
|
||||
expect(residual?.value).toBe(6); // 10 - 4
|
||||
expect(result.untrackedConsumption).toBe(0); // parent (top-level) subtracted
|
||||
});
|
||||
|
||||
it("suppresses a per-parent residual at or below the floor", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "child", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 10, child: 9.5 },
|
||||
untrackedFloor: 1,
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("untracked_"))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("groups a small-device cluster under its rendered parent, not into untracked", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "s1", included_in_stat: "parent" },
|
||||
{ stat_consumption: "s2", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 10, s1: 0.003, s2: 0.004 },
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_parent");
|
||||
expect(other?.value).toBeCloseTo(0.007, 10);
|
||||
expect(result.parentLinks.other_parent).toBe("parent");
|
||||
expect(result.links).toContainEqual({
|
||||
source: "parent",
|
||||
target: "other_parent",
|
||||
});
|
||||
// The cluster attaches to its parent, so home-level untracked only loses
|
||||
// the top-level parent (10), never the cluster total.
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSankeyDevicesByFloorAndArea", () => {
|
||||
const hass = {
|
||||
...createMockHass({
|
||||
"sensor.a": createMockEntityState("sensor.a", "1"),
|
||||
"sensor.b": createMockEntityState("sensor.b", "2"),
|
||||
}),
|
||||
entities: {
|
||||
"sensor.a": { entity_id: "sensor.a", area_id: "kitchen" },
|
||||
},
|
||||
areas: {
|
||||
kitchen: { area_id: "kitchen", name: "Kitchen", floor_id: "ground" },
|
||||
},
|
||||
floors: {
|
||||
ground: { floor_id: "ground", name: "Ground", level: 0 },
|
||||
},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
it("buckets devices by their entity's area and floor, unknown ones under no_area", () => {
|
||||
const nodes: Node[] = [
|
||||
{ id: "sensor.a", value: 3, index: 4 },
|
||||
{ id: "sensor.b", value: 2, index: 4 },
|
||||
];
|
||||
const { areas, floors } = groupSankeyDevicesByFloorAndArea(hass, nodes);
|
||||
expect(areas.kitchen.value).toBe(3);
|
||||
expect(areas.kitchen.devices.map((n) => n.id)).toEqual(["sensor.a"]);
|
||||
expect(floors.ground.value).toBe(3);
|
||||
expect(floors.ground.areas).toContain("kitchen");
|
||||
// sensor.b has no registry entry -> no_area
|
||||
expect(areas.no_area.devices.map((n) => n.id)).toEqual(["sensor.b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSankeyLayout", () => {
|
||||
const hass = createMockHass();
|
||||
|
||||
it("links top-level devices to the root and emits the untracked node above the floor", () => {
|
||||
const deviceNodes: SankeyDeviceNode[] = [{ id: "a", value: 10, index: 4 }];
|
||||
const { nodes, links } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes,
|
||||
parentLinks: {},
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 2,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
expect(links).toContainEqual({ source: "home", target: "a", value: 10 });
|
||||
const untracked = nodes.find((n) => n.id === "untracked");
|
||||
expect(untracked?.value).toBe(2);
|
||||
expect(links).toContainEqual({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses the untracked node at or below the floor", () => {
|
||||
const { nodes } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [{ id: "a", value: 10, index: 4 }],
|
||||
parentLinks: {},
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 0.5,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
expect(nodes.some((n) => n.id === "untracked")).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a non-home root node id (single-source water-flow case)", () => {
|
||||
const { links } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [{ id: "a", value: 4, index: 4 }],
|
||||
parentLinks: {},
|
||||
rootNodeId: "sensor.source",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 0,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
expect(links).toContainEqual({
|
||||
source: "sensor.source",
|
||||
target: "a",
|
||||
value: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("numbers device sections and the untracked node by section depth", () => {
|
||||
const { nodes } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [
|
||||
{ id: "parent", value: 10, index: 4 },
|
||||
{ id: "child", value: 4, index: 4 },
|
||||
],
|
||||
parentLinks: { child: "parent" },
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 2,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
// section 0 -> index 4, section 1 -> index 5
|
||||
expect(nodes.find((n) => n.id === "parent")?.index).toBe(4);
|
||||
expect(nodes.find((n) => n.id === "child")?.index).toBe(5);
|
||||
// untracked sits at 3 + deviceSections.length (2)
|
||||
expect(nodes.find((n) => n.id === "untracked")?.index).toBe(5);
|
||||
});
|
||||
|
||||
it("builds floor and area nodes and links devices through them", () => {
|
||||
const groupedHass = {
|
||||
...createMockHass({
|
||||
"sensor.a": createMockEntityState("sensor.a", "3"),
|
||||
"sensor.b": createMockEntityState("sensor.b", "2"),
|
||||
}),
|
||||
entities: {
|
||||
"sensor.a": { entity_id: "sensor.a", area_id: "kitchen" },
|
||||
},
|
||||
areas: {
|
||||
kitchen: { area_id: "kitchen", name: "Kitchen", floor_id: "ground" },
|
||||
},
|
||||
floors: {
|
||||
ground: { floor_id: "ground", name: "Ground", level: 0 },
|
||||
},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const { nodes, links } = buildSankeyLayout({
|
||||
hass: groupedHass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [
|
||||
{ id: "sensor.a", value: 3, index: 4 },
|
||||
{ id: "sensor.b", value: 2, index: 4 },
|
||||
],
|
||||
parentLinks: {},
|
||||
rootNodeId: "home",
|
||||
groupByFloor: true,
|
||||
groupByArea: true,
|
||||
untrackedConsumption: 0,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
|
||||
const floor = nodes.find((n) => n.id === "floor_ground");
|
||||
expect(floor?.index).toBe(2);
|
||||
expect(floor?.value).toBe(3);
|
||||
const area = nodes.find((n) => n.id === "area_kitchen");
|
||||
expect(area?.index).toBe(3);
|
||||
expect(area?.value).toBe(3);
|
||||
// root -> floor -> area -> device
|
||||
expect(links).toContainEqual({ source: "home", target: "floor_ground" });
|
||||
expect(links).toContainEqual({
|
||||
source: "floor_ground",
|
||||
target: "area_kitchen",
|
||||
value: 3,
|
||||
});
|
||||
expect(links).toContainEqual({
|
||||
source: "area_kitchen",
|
||||
target: "sensor.a",
|
||||
value: 3,
|
||||
});
|
||||
// sensor.b has no registry entry -> no_area -> linked straight to root
|
||||
expect(links).toContainEqual({
|
||||
source: "home",
|
||||
target: "sensor.b",
|
||||
value: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ShowToastParams } from "../../src/managers/notification-manager";
|
||||
import {
|
||||
registerServiceWorker,
|
||||
supportsServiceWorker,
|
||||
} from "../../src/util/register-service-worker";
|
||||
|
||||
class FakeWorker extends EventTarget {
|
||||
public state: ServiceWorkerState = "installing";
|
||||
|
||||
public postMessage = vi.fn();
|
||||
}
|
||||
|
||||
class FakeRegistration extends EventTarget {
|
||||
public installing: ServiceWorker | null = null;
|
||||
|
||||
public waiting: ServiceWorker | null = null;
|
||||
}
|
||||
|
||||
class FakeServiceWorkerContainer extends EventTarget {
|
||||
public controller: ServiceWorker | null = null;
|
||||
|
||||
public register = vi.fn();
|
||||
}
|
||||
|
||||
const serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"serviceWorker"
|
||||
);
|
||||
|
||||
const restoreServiceWorker = () => {
|
||||
if (serviceWorkerDescriptor) {
|
||||
Object.defineProperty(navigator, "serviceWorker", serviceWorkerDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
}
|
||||
};
|
||||
|
||||
describe("supportsServiceWorker", () => {
|
||||
afterEach(restoreServiceWorker);
|
||||
|
||||
it("returns true when the service worker API is available", () => {
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: new FakeServiceWorkerContainer(),
|
||||
});
|
||||
|
||||
expect(supportsServiceWorker()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the service worker API is unavailable", () => {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
expect(supportsServiceWorker()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerServiceWorker", () => {
|
||||
let root: HTMLElement;
|
||||
let worker: FakeWorker;
|
||||
let registration: FakeRegistration;
|
||||
let serviceWorker: FakeServiceWorkerContainer;
|
||||
let notifications: ShowToastParams[];
|
||||
|
||||
const latestNotification = () => notifications[notifications.length - 1];
|
||||
|
||||
const setDirtyState = (isDirty: boolean) => {
|
||||
window.isDirtyState = isDirty;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dirty-state-changed", { detail: { isDirty } })
|
||||
);
|
||||
};
|
||||
|
||||
const installUpdate = () => {
|
||||
registration.installing = worker as unknown as ServiceWorker;
|
||||
registration.dispatchEvent(new Event("updatefound"));
|
||||
worker.state = "installed";
|
||||
worker.dispatchEvent(new Event("statechange"));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
globalThis.__BUILD__ = "modern";
|
||||
globalThis.__DEV__ = false;
|
||||
globalThis.__DEMO__ = false;
|
||||
window.isDirtyState = false;
|
||||
notifications = [];
|
||||
root = document.createElement("div");
|
||||
root.addEventListener("hass-notification", (event) => {
|
||||
notifications.push((event as CustomEvent<ShowToastParams>).detail);
|
||||
});
|
||||
worker = new FakeWorker();
|
||||
registration = new FakeRegistration();
|
||||
serviceWorker = new FakeServiceWorkerContainer();
|
||||
serviceWorker.controller = worker as unknown as ServiceWorker;
|
||||
serviceWorker.register.mockResolvedValue(registration);
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: serviceWorker,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
latestNotification()?.secondaryAction?.action();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
window.isDirtyState = false;
|
||||
globalThis.__DEV__ = false;
|
||||
globalThis.__DEMO__ = false;
|
||||
restoreServiceWorker();
|
||||
});
|
||||
|
||||
it("registers the worker for the current build", async () => {
|
||||
await registerServiceWorker(root, false);
|
||||
|
||||
expect(serviceWorker.register).toHaveBeenCalledOnce();
|
||||
expect(serviceWorker.register).toHaveBeenCalledWith("/sw-modern.js");
|
||||
});
|
||||
|
||||
it("does not register when service workers are unsupported", async () => {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
await registerServiceWorker(root);
|
||||
|
||||
expect(serviceWorker.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("listens for controller changes before registering", async () => {
|
||||
const addEventListener = vi.spyOn(serviceWorker, "addEventListener");
|
||||
|
||||
await registerServiceWorker(root, false);
|
||||
|
||||
expect(addEventListener).toHaveBeenCalledWith(
|
||||
"controllerchange",
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(addEventListener.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
serviceWorker.register.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ notifyUpdate: false, dev: false, demo: false },
|
||||
{ notifyUpdate: true, dev: true, demo: false },
|
||||
{ notifyUpdate: true, dev: false, demo: true },
|
||||
])(
|
||||
"does not monitor updates with notifyUpdate=$notifyUpdate, dev=$dev, demo=$demo",
|
||||
async ({ notifyUpdate, dev, demo }) => {
|
||||
globalThis.__DEV__ = dev;
|
||||
globalThis.__DEMO__ = demo;
|
||||
const addEventListener = vi.spyOn(registration, "addEventListener");
|
||||
|
||||
await registerServiceWorker(root, notifyUpdate);
|
||||
|
||||
expect(addEventListener).not.toHaveBeenCalledWith(
|
||||
"updatefound",
|
||||
expect.any(Function)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("ignores update discovery without an installing worker", async () => {
|
||||
await registerServiceWorker(root);
|
||||
|
||||
registration.dispatchEvent(new Event("updatefound"));
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("waits for an installing worker to reach the installed state", async () => {
|
||||
await registerServiceWorker(root);
|
||||
registration.installing = worker as unknown as ServiceWorker;
|
||||
registration.dispatchEvent(new Event("updatefound"));
|
||||
|
||||
worker.state = "activating";
|
||||
worker.dispatchEvent(new Event("statechange"));
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores an initial installation without an existing controller", async () => {
|
||||
serviceWorker.controller = null;
|
||||
await registerServiceWorker(root);
|
||||
|
||||
installUpdate();
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("starts at 60 seconds when an update is installed", async () => {
|
||||
await registerServiceWorker(root);
|
||||
|
||||
installUpdate();
|
||||
|
||||
expect(latestNotification()).toMatchObject({
|
||||
id: "frontend-update-available",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
announceMessage: {
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
action: {
|
||||
primary: true,
|
||||
text: { translationKey: "ui.notification_toast.update_now" },
|
||||
},
|
||||
secondaryAction: {
|
||||
text: { translationKey: "ui.common.cancel" },
|
||||
},
|
||||
duration: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an already waiting worker", async () => {
|
||||
registration.waiting = worker as unknown as ServiceWorker;
|
||||
|
||||
await registerServiceWorker(root);
|
||||
|
||||
expect(latestNotification().message).toMatchObject({
|
||||
args: { seconds: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a waiting worker without an existing controller", async () => {
|
||||
registration.waiting = worker as unknown as ServiceWorker;
|
||||
serviceWorker.controller = null;
|
||||
|
||||
await registerServiceWorker(root);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ elapsed: 1_000, visual: 59, announced: 60 },
|
||||
{ elapsed: 20_000, visual: 40, announced: 40 },
|
||||
{ elapsed: 40_000, visual: 20, announced: 20 },
|
||||
{ elapsed: 55_000, visual: 5, announced: 5 },
|
||||
{ elapsed: 59_000, visual: 1, announced: 5 },
|
||||
])(
|
||||
"shows $visual seconds and announces $announced after $elapsed ms",
|
||||
async ({ elapsed, visual, announced }) => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
vi.advanceTimersByTime(elapsed);
|
||||
|
||||
expect(latestNotification().message).toMatchObject({
|
||||
args: { seconds: visual },
|
||||
});
|
||||
expect(latestNotification().announceMessage).toMatchObject({
|
||||
args: { seconds: announced },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it("activates and hides the update when the countdown finishes", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(worker.postMessage).toHaveBeenCalledOnce();
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
|
||||
expect(latestNotification()).toMatchObject({
|
||||
id: "frontend-update-available",
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("activates and hides the update immediately on request", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
latestNotification().action!.action();
|
||||
|
||||
expect(worker.postMessage).toHaveBeenCalledOnce();
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
|
||||
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
|
||||
});
|
||||
|
||||
it("cancels automatic activation", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
latestNotification().secondaryAction!.action();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers the message and activation while the page is dirty", async () => {
|
||||
setDirtyState(true);
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
setDirtyState(false);
|
||||
expect(latestNotification().message).toMatchObject({
|
||||
args: { seconds: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides and resets the countdown when the page becomes dirty", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
setDirtyState(true);
|
||||
|
||||
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
setDirtyState(false);
|
||||
expect(latestNotification().message).toMatchObject({
|
||||
args: { seconds: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the latest installed worker", async () => {
|
||||
await registerServiceWorker(root);
|
||||
installUpdate();
|
||||
const replacement = new FakeWorker();
|
||||
replacement.state = "installed";
|
||||
|
||||
registration.installing = replacement as unknown as ServiceWorker;
|
||||
registration.dispatchEvent(new Event("updatefound"));
|
||||
replacement.dispatchEvent(new Event("statechange"));
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
expect(replacement.postMessage).toHaveBeenCalledWith({
|
||||
type: "skipWaiting",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4638,22 +4638,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/client@npm:1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "@rsdoctor/client@npm:1.6.0"
|
||||
checksum: 10/c6311104c59978d486146d6e2e80fe2e58c1f783a21a2a5ede6dc2db59ab4db86a2f551b8b54cfad01df06bc28e3def8792cea2fa2f68f07861d4f2b0cf56503
|
||||
"@rsdoctor/client@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/client@npm:1.6.1"
|
||||
checksum: 10/aefc3a378e3ecdc9f03c21272dfb06d4eb68ca5395d83edb0a44fd816ea96b45e894de8c862e605fcb02611d306014ffef4ca09723945cf75c9a33b7062d5d4b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/core@npm:1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "@rsdoctor/core@npm:1.6.0"
|
||||
"@rsdoctor/core@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/core@npm:1.6.1"
|
||||
dependencies:
|
||||
"@rsbuild/plugin-check-syntax": "npm:^1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.6.0"
|
||||
"@rsdoctor/sdk": "npm:1.6.0"
|
||||
"@rsdoctor/types": "npm:1.6.0"
|
||||
"@rsdoctor/utils": "npm:1.6.0"
|
||||
"@rsdoctor/graph": "npm:1.6.1"
|
||||
"@rsdoctor/sdk": "npm:1.6.1"
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@rsdoctor/utils": "npm:1.6.1"
|
||||
"@rspack/resolver": "npm:^0.2.8"
|
||||
browserslist-load-config: "npm:^1.0.2"
|
||||
es-toolkit: "npm:^1.49.0"
|
||||
@@ -4661,60 +4661,60 @@ __metadata:
|
||||
fs-extra: "npm:^11.1.1"
|
||||
semver: "npm:^7.8.5"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/6e39bd687844fb309dfb294278dec0fcfabc50edd1e42bac51bff30504971a3567bf344156d6cb63078ba441d5f78a3032e8b3cd487598e5a5903f5034dea8ed
|
||||
checksum: 10/de5708e15e1efd67a23d753ed906e573ca6a6857428dbdfea0aff2f85f1ed3f631ff34e23bf40e955855ed878ed548c073c620a4edbd12959c20a3f9ff9c1cfd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/graph@npm:1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "@rsdoctor/graph@npm:1.6.0"
|
||||
"@rsdoctor/graph@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/graph@npm:1.6.1"
|
||||
dependencies:
|
||||
"@rsdoctor/types": "npm:1.6.0"
|
||||
"@rsdoctor/utils": "npm:1.6.0"
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@rsdoctor/utils": "npm:1.6.1"
|
||||
es-toolkit: "npm:^1.49.0"
|
||||
path-browserify: "npm:1.0.1"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/21ec99112e34f2fd855e44e6f82c4b791a90023b2e5fd23439874b23bc295f8e614134d089eeea2a7f9ec4e482daf539eafbc5af0bd6013a71572cedb8ecd0ef
|
||||
checksum: 10/065c485f11939143021c12a1acff47a2539c22e9a9d5f8501c906578213ca16b71a35d08bc0a83035529d42a7906fc098b6e3c536d895cd13ee74b5340a39e40
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/rspack-plugin@npm:1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.6.0"
|
||||
"@rsdoctor/rspack-plugin@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.6.1"
|
||||
dependencies:
|
||||
"@rsdoctor/core": "npm:1.6.0"
|
||||
"@rsdoctor/graph": "npm:1.6.0"
|
||||
"@rsdoctor/sdk": "npm:1.6.0"
|
||||
"@rsdoctor/types": "npm:1.6.0"
|
||||
"@rsdoctor/utils": "npm:1.6.0"
|
||||
"@rsdoctor/core": "npm:1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.6.1"
|
||||
"@rsdoctor/sdk": "npm:1.6.1"
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@rsdoctor/utils": "npm:1.6.1"
|
||||
peerDependencies:
|
||||
"@rspack/core": "*"
|
||||
peerDependenciesMeta:
|
||||
"@rspack/core":
|
||||
optional: true
|
||||
checksum: 10/88b68253c81a07048f9f33197a50a91381a1587137d822c9fa6fee51c346926f79bc4f57c1cec8afe8639539520b31c1daa74e4a22ce293d8109ef2beff9904e
|
||||
checksum: 10/8908f4a3c2b46fdaba2c6d39dd535623ddcc411fb50d358de949142a5628a512827f5206a93ae975bd93e5de6b72d5cd71f63463c65a37ba718727567187ce8e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/sdk@npm:1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "@rsdoctor/sdk@npm:1.6.0"
|
||||
"@rsdoctor/sdk@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/sdk@npm:1.6.1"
|
||||
dependencies:
|
||||
"@rsdoctor/client": "npm:1.6.0"
|
||||
"@rsdoctor/graph": "npm:1.6.0"
|
||||
"@rsdoctor/types": "npm:1.6.0"
|
||||
"@rsdoctor/utils": "npm:1.6.0"
|
||||
"@rsdoctor/client": "npm:1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.6.1"
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@rsdoctor/utils": "npm:1.6.1"
|
||||
launch-editor: "npm:^2.13.2"
|
||||
safer-buffer: "npm:2.1.2"
|
||||
socket.io: "npm:4.8.1"
|
||||
tapable: "npm:2.3.3"
|
||||
checksum: 10/f16cb445a6669ae8427b14228de4d049b64ff81b139b5c614663ad7407fe111590fde9cecd1c10a633753d3118ba042c737622720149cf2db5ae2799fa38a673
|
||||
checksum: 10/d667d62d730bed7606bcf1ed2063fb7597024e31d9108c3ba9729313764866407c216499ccb3c7102f239d7d82463e510f2b2ec71270af6a40039cbe6f876d23
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/types@npm:1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "@rsdoctor/types@npm:1.6.0"
|
||||
"@rsdoctor/types@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/types@npm:1.6.1"
|
||||
dependencies:
|
||||
"@types/connect": "npm:3.4.38"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
@@ -4728,16 +4728,16 @@ __metadata:
|
||||
optional: true
|
||||
webpack:
|
||||
optional: true
|
||||
checksum: 10/7348ceaab14d2d5365ca038b3b555576034456c6a135b4cd2bff6d2eef9039072492d3cdcb0841f609a49197dbcbc9005c89026b89370e25f1aa7231fb484942
|
||||
checksum: 10/1c4ae6c1aa6fa525d65ab78396954d86358880959ee3fdb68247c2f3215a35e4cb2aab3836cb82f6c208c2c3834cfe0dc8bf575ba59fd8e2ff89ffc92322fcad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/utils@npm:1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "@rsdoctor/utils@npm:1.6.0"
|
||||
"@rsdoctor/utils@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/utils@npm:1.6.1"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.26.2"
|
||||
"@rsdoctor/types": "npm:1.6.0"
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
acorn: "npm:^8.10.0"
|
||||
acorn-import-attributes: "npm:^1.9.5"
|
||||
@@ -4751,69 +4751,69 @@ __metadata:
|
||||
picocolors: "npm:^1.1.1"
|
||||
rslog: "npm:^2.1.2"
|
||||
strip-ansi: "npm:^7.2.0"
|
||||
checksum: 10/a01a68837abf5e47d979757bb8b59f68838beaf80a85acc462e89671155adf51fed68ac41780aeb36eae45e93baff1269757446970e58d5663b2ad5661db465a
|
||||
checksum: 10/11f4e9136dd849fa05b0ab56ab0de3be3de43880d530dcbfb1835780dccad9976636f6a743852893948ae053e71fb7cd6144cac3ffac27929a87710e3981c048
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-arm64@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.1.4"
|
||||
"@rspack/binding-darwin-arm64@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.1.5"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-x64@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.1.4"
|
||||
"@rspack/binding-darwin-x64@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.1.5"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.4"
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.5"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.4"
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.5"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.4"
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.5"
|
||||
conditions: os=linux & cpu=riscv64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.4"
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.5"
|
||||
conditions: os=linux & cpu=riscv64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.4"
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.5"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-musl@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.4"
|
||||
"@rspack/binding-linux-x64-musl@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.5"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-wasm32-wasi@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.4"
|
||||
"@rspack/binding-wasm32-wasi@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.5"
|
||||
dependencies:
|
||||
"@emnapi/core": "npm:1.11.2"
|
||||
"@emnapi/runtime": "npm:1.11.2"
|
||||
@@ -4822,43 +4822,43 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.4"
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.5"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.4"
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.5"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.4"
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.5"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/binding@npm:2.1.4"
|
||||
"@rspack/binding@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding@npm:2.1.5"
|
||||
dependencies:
|
||||
"@rspack/binding-darwin-arm64": "npm:2.1.4"
|
||||
"@rspack/binding-darwin-x64": "npm:2.1.4"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.1.4"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.1.4"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.4"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.1.4"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.1.4"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.1.4"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.1.4"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.1.4"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.1.4"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.1.4"
|
||||
"@rspack/binding-darwin-arm64": "npm:2.1.5"
|
||||
"@rspack/binding-darwin-x64": "npm:2.1.5"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.1.5"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.1.5"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.5"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.1.5"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.1.5"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.1.5"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.1.5"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.1.5"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.1.5"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.1.5"
|
||||
dependenciesMeta:
|
||||
"@rspack/binding-darwin-arm64":
|
||||
optional: true
|
||||
@@ -4884,15 +4884,15 @@ __metadata:
|
||||
optional: true
|
||||
"@rspack/binding-win32-x64-msvc":
|
||||
optional: true
|
||||
checksum: 10/425bf152dba708992ce16114ce6bc8dfa424071694e85201317518dce1899eeae873a2adbd23b214bb8d223f7290e000fd78ad9bf262c633e12d3d4ff73bbdcd
|
||||
checksum: 10/d1054348d3ba734485f977d574c6311311707adfcc5e53f03c8dbbbdc5778cff9b815e1d675992e2832241606d4cce7678acc3999b259139120b4ec30751f1e1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/core@npm:2.1.4":
|
||||
version: 2.1.4
|
||||
resolution: "@rspack/core@npm:2.1.4"
|
||||
"@rspack/core@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/core@npm:2.1.5"
|
||||
dependencies:
|
||||
"@rspack/binding": "npm:2.1.4"
|
||||
"@rspack/binding": "npm:2.1.5"
|
||||
peerDependencies:
|
||||
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
|
||||
"@swc/helpers": ^0.5.23
|
||||
@@ -4901,7 +4901,7 @@ __metadata:
|
||||
optional: true
|
||||
"@swc/helpers":
|
||||
optional: true
|
||||
checksum: 10/3c7aa9e8dbe8b132b51fc017a6c2c76ddf346cf663cad2a8912ba61469d80468acd045503d405bf962bb5ec44f44036512dd6d6233ea6d1419979e5c17e1dbd8
|
||||
checksum: 10/714064de701211724f7d859bc269d357fa2ad2f7ea0ac34d1e4e48b534981cf3961bd723c0659b9c093330f04e01e16a0c5587d6a18301727e27d717f764ded7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5761,105 +5761,105 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/eslint-plugin@npm:8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.64.0"
|
||||
"@typescript-eslint/eslint-plugin@npm:8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.65.0"
|
||||
dependencies:
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
"@typescript-eslint/scope-manager": "npm:8.64.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.64.0"
|
||||
"@typescript-eslint/utils": "npm:8.64.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.64.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.65.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.65.0"
|
||||
"@typescript-eslint/utils": "npm:8.65.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.65.0"
|
||||
ignore: "npm:^7.0.5"
|
||||
natural-compare: "npm:^1.4.0"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
"@typescript-eslint/parser": ^8.64.0
|
||||
"@typescript-eslint/parser": ^8.65.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/ec7cbcb44968386a4b9aff9272ce6b75bdcc7f398db5f2d07a21854baf0364ca33c74268c0e19d21386c6b87b9358b141df7bef3d26e4e4e7a9eb5f8f394dc75
|
||||
checksum: 10/20b1e5fd0c01d450c345750582e4911affef8ba934b2b78953ff593102d2a01f21c5f6469e13239fdd6a0e30152d6122906e7623f53aa8c937c6faae9407be47
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/parser@npm:8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.64.0"
|
||||
"@typescript-eslint/parser@npm:8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.65.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager": "npm:8.64.0"
|
||||
"@typescript-eslint/types": "npm:8.64.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.64.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.64.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.65.0"
|
||||
"@typescript-eslint/types": "npm:8.65.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.65.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.65.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/7239b16a6ca6bb1764ad04bc1eb46997336541d049fcffb4966ef404fbb02324b2b33aed50c2b976359ec8d3c97b90668cfd6aa9177228b4b799152f01a3904a
|
||||
checksum: 10/55f68666953c02c8adae35a46076848da6456181b1849a28ec836a5866ca37b902f475fb4c32ba7aa3a6a7e5d66828df8859edec297da09181fb937aeea30f8e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/project-service@npm:8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.64.0"
|
||||
"@typescript-eslint/project-service@npm:8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.65.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.64.0"
|
||||
"@typescript-eslint/types": "npm:^8.64.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.65.0"
|
||||
"@typescript-eslint/types": "npm:^8.65.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/511b9a8f1dcb32c99003cab9791309056ac6e889fe46227600deb743e869a63b3e78d7d2d3ef1bf65b2d5e574b73add3040fbef4c0f94aed587368aaea149559
|
||||
checksum: 10/915662449a66d90f03661a805f7c62a5efaa8f7887671272e08b684b41edab51a9021f47aac4d78001a0f87960e8587adc037424d56f13de883e0ce699e7ca55
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/scope-manager@npm:8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.64.0"
|
||||
"@typescript-eslint/scope-manager@npm:8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.65.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.64.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.64.0"
|
||||
checksum: 10/3c72c4915cee19d632ddc7491c3a4668dd04aa8bcb112fde8ac10aea2cc0364aaa8439d9939d0c62a7b4159fc22f4ced7cba3a77df4422b21d76497068f08076
|
||||
"@typescript-eslint/types": "npm:8.65.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.65.0"
|
||||
checksum: 10/038e208c907aa45fe5bb7168e1dccf89c1fc6678d1715a9c04f4b332d4e79ab719b5b43a4e0c2d5a183ea863813ff59fda040b2717fe5517468453af6b99a50a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.64.0, @typescript-eslint/tsconfig-utils@npm:^8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.64.0"
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.65.0, @typescript-eslint/tsconfig-utils@npm:^8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.65.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/905469c5067a9a2d18d065848c6497081ab787b22a9d7c06499f4bb593b7d67f9fb17e7861a114c46626555853cc52d4542db5311c8dc7cd138e45461a73e589
|
||||
checksum: 10/f88253a4df1d599a1bebeeb403611538485e22c52213f2f2b9c435bde8ebce64645d6bb985c900b01ef221032835fcd4f6fea4b94d178220c18de5d93af905c4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/type-utils@npm:8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.64.0"
|
||||
"@typescript-eslint/type-utils@npm:8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.65.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.64.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.64.0"
|
||||
"@typescript-eslint/utils": "npm:8.64.0"
|
||||
"@typescript-eslint/types": "npm:8.65.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.65.0"
|
||||
"@typescript-eslint/utils": "npm:8.65.0"
|
||||
debug: "npm:^4.4.3"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/7ab1fd8c0d292c0155cf137d316b1618681eca0fe4cf446a05d909de41311ec6bb64fed82513e822063a962508d5b3e615fb0155a4a565d6b9c6cb81d06a5cd2
|
||||
checksum: 10/d52e0c341c9731d8f3bfd2f475bbcd5a5632434e11fc39e8e21acb706145bedb8c6c1998b8ced73e132b43179dd95f2c318effc36c9a1dd61f14546b07b25852
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/types@npm:8.64.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/types@npm:8.64.0"
|
||||
checksum: 10/b8951c00ce9b9702f3201f017354774ea5f39c30c2b6f815cb50d91f53f61c325e30f548329daf731d5169d752095cf102da54b754fb202bcfb619faa56fa9f4
|
||||
"@typescript-eslint/types@npm:8.65.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/types@npm:8.65.0"
|
||||
checksum: 10/a6fc10a733adbb98bbb9c312c8d99791e1a2fd3efef5c2a5b51da1c166aac3db4427c30bf32059808abe305a289f820bf610683914e897baeb18315af6a1d16c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/typescript-estree@npm:8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.64.0"
|
||||
"@typescript-eslint/typescript-estree@npm:8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.65.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service": "npm:8.64.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.64.0"
|
||||
"@typescript-eslint/types": "npm:8.64.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.64.0"
|
||||
"@typescript-eslint/project-service": "npm:8.65.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.65.0"
|
||||
"@typescript-eslint/types": "npm:8.65.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.65.0"
|
||||
debug: "npm:^4.4.3"
|
||||
minimatch: "npm:^10.2.2"
|
||||
semver: "npm:^7.7.3"
|
||||
@@ -5867,32 +5867,32 @@ __metadata:
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/833101d25e820d1d0e317dfc9beca985b3c87e5458b20ed002c86c2d425667aa506f756f1759b54f724033effb529266f836f912c460ee662f347fb43dded6ed
|
||||
checksum: 10/711fdb5eff67ff34437c56a1a661b16a2e1d6bcd96c9f96196c4242b8d4ddaea8e835ca8badd340c703e043d8dfe8cfca90b32eca60069a4f1d2880afdb670fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/utils@npm:8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.64.0"
|
||||
"@typescript-eslint/utils@npm:8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.65.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
||||
"@typescript-eslint/scope-manager": "npm:8.64.0"
|
||||
"@typescript-eslint/types": "npm:8.64.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.64.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.65.0"
|
||||
"@typescript-eslint/types": "npm:8.65.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.65.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/ff7e5374bd6ef60d7df723e6440468e3a5d4879d1d9876e322904319a1f1d2f98d858fc9b9169dbbd7ee0786a07102a058f13e2b6c16d1bf9aae9eb836a258a3
|
||||
checksum: 10/c1dcd555b58aef1e066164978335e521809acac36b56bd6a6dae62cffae80f3ea5f43527506be76dfef0fe3d4c8382a24355a28867c4904b0a7729691ba45656
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/visitor-keys@npm:8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.64.0"
|
||||
"@typescript-eslint/visitor-keys@npm:8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.65.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.64.0"
|
||||
"@typescript-eslint/types": "npm:8.65.0"
|
||||
eslint-visitor-keys: "npm:^5.0.0"
|
||||
checksum: 10/d149be4ac0e67c51097cdb7335d8e2d29b9dc0de173961c5cc550e938a00a3af28b1f8ecd7ad832fcfe489d1b11e36fdd394b6ea92115a7558123562e31a704f
|
||||
checksum: 10/e7f86d21f0bf03ca7cff9fa9428aab98620564d15fb06c9f56a294c13cee324624d42f9115f3d76fbad92266220479cca334b5c66562f885da5c4d2bda3d87b3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7671,10 +7671,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"color-name@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "color-name@npm:2.1.0"
|
||||
checksum: 10/eb014f71d87408e318e95d3f554f188370d354ba8e0ffa4341d0fd19de391bfe2bc96e563d4f6614644d676bc24f475560dffee3fe310c2d6865d007410a9a2b
|
||||
"color-name@npm:2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "color-name@npm:2.1.1"
|
||||
checksum: 10/17bbb03a1e64299e5f61ccc514c502baf66980b88d013ca0161e8c0009edc3813ab3ffc8558ae99b8633d23c2e94b6551a248015b86cb5f3a84b645b4d0f3bb5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9148,9 +9148,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"fast-uri@npm:^3.0.1":
|
||||
version: 3.1.3
|
||||
resolution: "fast-uri@npm:3.1.3"
|
||||
checksum: 10/7969a50a327482035d5c8c93faf51b26d7a93ce62bc750c91b3df158550004b5fbb378c95c900fd71f4dded36b5a78d1c666fb8043bb1214efbaebd8518d873e
|
||||
version: 3.1.4
|
||||
resolution: "fast-uri@npm:3.1.4"
|
||||
checksum: 10/1c1ff8e7b6f7b38e997b1528aa2c97e290e0173d51250bfe4e11a303e454fc297a287555b5cb2ded59dbfce7876855fb15994a1a6b439f2497a2b8926513e397
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9954,8 +9954,8 @@ __metadata:
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@playwright/test": "npm:1.61.1"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.0"
|
||||
"@rspack/core": "npm:2.1.4"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.1"
|
||||
"@rspack/core": "npm:2.1.5"
|
||||
"@rspack/dev-server": "npm:2.1.0"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
@@ -9986,7 +9986,7 @@ __metadata:
|
||||
barcode-detector: "npm:3.2.1"
|
||||
browserslist-useragent-regexp: "npm:4.1.4"
|
||||
cally: "npm:0.9.2"
|
||||
color-name: "npm:2.1.0"
|
||||
color-name: "npm:2.1.1"
|
||||
comlink: "npm:4.4.2"
|
||||
core-js: "npm:3.49.0"
|
||||
cropperjs: "npm:1.6.2"
|
||||
@@ -10023,14 +10023,14 @@ __metadata:
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.12"
|
||||
js-yaml: "npm:5.2.1"
|
||||
js-yaml: "npm:5.2.2"
|
||||
jsdom: "npm:29.1.1"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
|
||||
leaflet.markercluster: "npm:1.5.3"
|
||||
license-checker-rseidelsohn: "npm:5.0.1"
|
||||
lint-staged: "npm:17.1.0"
|
||||
lint-staged: "npm:17.2.0"
|
||||
lit: "npm:3.3.3"
|
||||
lit-analyzer: "npm:2.0.3"
|
||||
lit-html: "npm:3.3.3"
|
||||
@@ -10038,13 +10038,13 @@ __metadata:
|
||||
lodash.template: "npm:4.18.1"
|
||||
luxon: "npm:3.7.2"
|
||||
map-stream: "npm:0.0.7"
|
||||
marked: "npm:18.0.6"
|
||||
marked: "npm:18.0.7"
|
||||
memoize-one: "npm:6.0.0"
|
||||
minify-literals: "npm:2.1.0"
|
||||
node-vibrant: "npm:4.0.4"
|
||||
object-hash: "npm:3.0.0"
|
||||
pinst: "npm:3.0.0"
|
||||
prettier: "npm:3.9.5"
|
||||
prettier: "npm:3.9.6"
|
||||
punycode: "npm:2.3.1"
|
||||
qr-scanner: "npm:1.4.2"
|
||||
qrcode: "npm:1.5.4"
|
||||
@@ -10052,16 +10052,16 @@ __metadata:
|
||||
rrule: "npm:2.8.1"
|
||||
rspack-manifest-plugin: "npm:5.2.2"
|
||||
serve: "npm:14.2.6"
|
||||
sinon: "npm:22.0.0"
|
||||
sinon: "npm:22.1.0"
|
||||
sortablejs: "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch"
|
||||
stacktrace-js: "npm:2.0.2"
|
||||
superstruct: "npm:2.0.2"
|
||||
tar: "npm:7.5.20"
|
||||
tar: "npm:7.5.21"
|
||||
terser-webpack-plugin: "npm:5.6.1"
|
||||
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
|
||||
ts-lit-plugin: "npm:2.0.2"
|
||||
typescript: "npm:6.0.3"
|
||||
typescript-eslint: "npm:8.64.0"
|
||||
typescript-eslint: "npm:8.65.0"
|
||||
vite-tsconfig-paths: "npm:6.1.1"
|
||||
vitest: "npm:4.1.10"
|
||||
webpack-stats-plugin: "npm:1.1.3"
|
||||
@@ -10957,14 +10957,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:5.2.1":
|
||||
version: 5.2.1
|
||||
resolution: "js-yaml@npm:5.2.1"
|
||||
"js-yaml@npm:5.2.2":
|
||||
version: 5.2.2
|
||||
resolution: "js-yaml@npm:5.2.2"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.mjs
|
||||
checksum: 10/e1eca2d21c15572585bb236d9fde31d6789eb50b9c63e8753fa7e0777bc480f7521cad517bd7a0c66f27dfc27ddcd7100beeefa51c1a50e10e98f2e009633c3d
|
||||
checksum: 10/2b4c2933af12c97e1c4894a4f27fe9b06dab70a64a96bb50624b4429bef6bf11008bde20d868bce52a36784473314efc30078ba6025b58cf7537961e23b1ae9c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11423,9 +11423,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lint-staged@npm:17.1.0":
|
||||
version: 17.1.0
|
||||
resolution: "lint-staged@npm:17.1.0"
|
||||
"lint-staged@npm:17.2.0":
|
||||
version: 17.2.0
|
||||
resolution: "lint-staged@npm:17.2.0"
|
||||
dependencies:
|
||||
picomatch: "npm:^4.0.5"
|
||||
string-argv: "npm:^0.3.2"
|
||||
@@ -11436,7 +11436,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
lint-staged: bin/lint-staged.js
|
||||
checksum: 10/050d8ce1f6c2cf218ebc0b693de6d1754b333f04e263ecbee230b1aa742fd684667d0f42beee48c3bb11cbcc4e50c4995289c82c36bb08f6bd0f8157816514e1
|
||||
checksum: 10/7c26129d6dd27f20d8fc8d70b13252f15baf569124721d7f5e9ed289e054ed43f5cc2a35de4129b65a7c7f1ad4a5900859a7488f8a1f0b96428b995b29ec2ba8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11681,12 +11681,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:18.0.6":
|
||||
version: 18.0.6
|
||||
resolution: "marked@npm:18.0.6"
|
||||
"marked@npm:18.0.7":
|
||||
version: 18.0.7
|
||||
resolution: "marked@npm:18.0.7"
|
||||
bin:
|
||||
marked: bin/marked.js
|
||||
checksum: 10/ab4747d071888726a91ccf381416366c17345c6cdf6bf8739d114482f633740bb9227aff03324d20b1e14c8052f4c6b523c5d5974f7346b240aef45c900c7d27
|
||||
checksum: 10/7ea7b8556a9e8cab2881b194815a7550c61d77639c6b8f8d9022f96790fd2b289b2ea28969cea6e01c24c3b8ec822373288ee02b7c50f178bc3ec79aa42d05f1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -12878,12 +12878,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prettier@npm:3.9.5":
|
||||
version: 3.9.5
|
||||
resolution: "prettier@npm:3.9.5"
|
||||
"prettier@npm:3.9.6":
|
||||
version: 3.9.6
|
||||
resolution: "prettier@npm:3.9.6"
|
||||
bin:
|
||||
prettier: bin/prettier.cjs
|
||||
checksum: 10/b6587f1582ba653ce5207ee227aff646500e5098646931088f176d1ab090b1f47d5a333f1facd634e9aa7dcb15c45940e2cec13a5962534cf0200617517b67b6
|
||||
checksum: 10/1dd1a1e0e40ec3b91cda9d294c4a95022aef61880ca3f441aad99b1252279f58a766c4cece81132c01550dcff1fd445efbd8812ea4a2374313e7fc6c8136f11f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13879,15 +13879,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sinon@npm:22.0.0":
|
||||
version: 22.0.0
|
||||
resolution: "sinon@npm:22.0.0"
|
||||
"sinon@npm:22.1.0":
|
||||
version: 22.1.0
|
||||
resolution: "sinon@npm:22.1.0"
|
||||
dependencies:
|
||||
"@sinonjs/commons": "npm:^3.0.1"
|
||||
"@sinonjs/fake-timers": "npm:^15.4.0"
|
||||
"@sinonjs/samsam": "npm:^10.0.2"
|
||||
diff: "npm:^9.0.0"
|
||||
checksum: 10/5d0a692c2f1cc463b86d18a57e8db80e1a7f5829252edfc4d11162a555e4d005bf03698549d7cf9ad65f79b7e77a4f87534b4c76220bb81a1ec5e2b30be19929
|
||||
checksum: 10/f49455cc9613c80765350a39cc2868ce238c35c5ba738e9075a5113f81951a616850e5ebbe77e19f8494ed24780b6a5d3496300cb1b76c12020f77adc72e0232
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14501,16 +14501,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tar@npm:*, tar@npm:7.5.20, tar@npm:^7.4.3, tar@npm:^7.5.4":
|
||||
version: 7.5.20
|
||||
resolution: "tar@npm:7.5.20"
|
||||
"tar@npm:*, tar@npm:7.5.21, tar@npm:^7.4.3, tar@npm:^7.5.4":
|
||||
version: 7.5.21
|
||||
resolution: "tar@npm:7.5.21"
|
||||
dependencies:
|
||||
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||
chownr: "npm:^3.0.0"
|
||||
minipass: "npm:^7.1.2"
|
||||
minizlib: "npm:^3.1.0"
|
||||
yallist: "npm:^5.0.0"
|
||||
checksum: 10/90a0fe423ac921197ad5eefc5e5f7ad7f42b06e80444c8c347c1e4112384cbe9cb53ade3ef71748eed3684888756869c2aeef6b6303c766101f3217e254d4be9
|
||||
checksum: 10/a1b7dcee15e4f7dbef7f07c3cf0fe946248efabd1cb029b54c698d817dfc2876c75bcaa164a12dd21191e385759ce3e37fea562cf4aabaa00984ef9ed3c48d17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14950,18 +14950,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript-eslint@npm:8.64.0":
|
||||
version: 8.64.0
|
||||
resolution: "typescript-eslint@npm:8.64.0"
|
||||
"typescript-eslint@npm:8.65.0":
|
||||
version: 8.65.0
|
||||
resolution: "typescript-eslint@npm:8.65.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.64.0"
|
||||
"@typescript-eslint/parser": "npm:8.64.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.64.0"
|
||||
"@typescript-eslint/utils": "npm:8.64.0"
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.65.0"
|
||||
"@typescript-eslint/parser": "npm:8.65.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.65.0"
|
||||
"@typescript-eslint/utils": "npm:8.65.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/7812e25506003c2a5b395a51ca3c31929028485d191049e6a813e65829e79fcebb07251ba42c5d75af8d15233a5bbb44dd37138345787779eabe07b88fcf75ec
|
||||
checksum: 10/5b2242f59005afdd57190849be7df2e86ce33b007fa0bf605f700ad0e38ea14fc14c48deafdf4a039614c1f012b71c61a19edb27f76d52fdc924cde476a100d1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user