mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-23 11:37:30 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89d807a520 | |||
| c2b6d6560a | |||
| b506cfdc54 | |||
| 65421584a3 | |||
| 8a93e40b8c | |||
| 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,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
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/`.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
@@ -46,6 +44,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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user