Compare commits

..

9 Commits

Author SHA1 Message Date
Aidan Timson 89d807a520 Correct context API ownership 2026-07-23 11:47:25 +01:00
Aidan Timson c2b6d6560a Clarify gallery guidance ownership 2026-07-23 11:34:03 +01:00
Aidan Timson b506cfdc54 Move gallery guidance into a skill 2026-07-23 11:17:37 +01:00
Aidan Timson 65421584a3 Clarify frontend benchmark requirements 2026-07-23 11:06:53 +01:00
Aidan Timson 8a93e40b8c Correct frontend component and context guidance 2026-07-23 11:06:42 +01:00
dependabot[bot] b6813ce060 Bump fast-uri from 3.1.3 to 3.1.4 (#53248)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 10:30:41 +01:00
Paul Bottein 3a060feb82 Unify number slider threshold across rows and state cards (#53224)
* Unify number slider threshold across rows and state cards

* Add showNumberSlider test
2026-07-23 09:37:12 +02:00
Petar Petrov ab39c4dd4a Group small device flows into "Other" in sankey cards and share logic (#53235)
The water and energy sankey cards skipped device flows below a hardcoded
0.01 while still counting them in the home total, so small-but-real
devices (e.g. 0.004 m³) were misattributed to "Untracked consumption".
Adopt the power/water-flow relative-threshold + "Other"-grouping strategy
and extract the logic shared by all four cards into energy/common/sankey.
2026-07-23 08:52:54 +02:00
Petar Petrov 82167a81f7 Keep ha-qr-code legible when theme colors collide (#53246)
Ensure ha-qr-code stays legible when theme colors collide
2026-07-23 09:43:31 +03:00
26 changed files with 1308 additions and 2783 deletions
+14 -9
View File
@@ -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 -16
View File
@@ -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.
+2 -2
View File
@@ -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,
});
```
+1 -2
View File
@@ -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
+35 -18
View File
@@ -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"),
-63
View File
@@ -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;
+14
View File
@@ -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 };
}
@@ -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
+2 -4
View File
@@ -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
-42
View File
@@ -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",
+52
View File
@@ -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,
});
});
});
+3 -3
View File
@@ -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