Compare commits

..
Author SHA1 Message Date
Petar Petrov 6c4d6b50b0 Show per-mount storage usage on the storage page
Each active mount now fetches its own usage from the Supervisor and renders
a bar with a used-of-total line under its address. The requests are not
awaited, so a slow or unreachable server cannot hold up the rows, and a
failed one simply leaves that row without usage.

fetchHostDisksUsage now takes a disk and an optional max_depth. The data
disk callers keep their depth of 3; mounts send no depth at all, since
walking one costs a round trip per directory and the Supervisor already
has a sensible per-target default.
2026-08-17 15:09:01 +03:00
30 changed files with 555 additions and 2680 deletions
+1 -1
View File
@@ -107,7 +107,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"js-yaml": "5.3.0",
"js-yaml": "5.2.3",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
+1 -1
View File
@@ -216,7 +216,7 @@ export class HaFilterDomains extends LitElement {
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-start: initial;
margin-inline-end: 8px;
}
ha-check-list-item {
-73
View File
@@ -1,73 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./chips/ha-assist-chip";
import "./ha-svg-icon";
/**
* Chip that opens a filter pane, with a badge showing how many filters are
* active.
*/
@customElement("ha-filter-pane-chip")
export class HaFilterPaneChip extends LitElement {
@property() public label = "";
/** SVG path of the leading icon. */
@property() public path?: string;
/** Number of active filters, shown as a badge when there is at least one. */
@property({ type: Number }) public count = 0;
@property({ type: Boolean }) public active = false;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`
<ha-assist-chip
.label=${this.label}
.active=${this.active}
.disabled=${this.disabled}
>
${
this.path
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
: nothing
}
</ha-assist-chip>
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
`;
}
static styles = css`
:host {
position: relative;
display: inline-block;
--ha-assist-chip-container-shape: 10px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0 2px;
color: var(--text-primary-color);
pointer-events: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane-chip": HaFilterPaneChip;
}
}
+10 -3
View File
@@ -126,11 +126,18 @@ export const listDatadisks = async (
timeout: null,
});
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
// `disk` is "default" for the data disk, or a mount name. Omitting maxDepth
// leaves the depth to the Supervisor, which defaults per target — walking a
// mount costs a round trip per directory, so mounts want no depth at all.
export const fetchHostDisksUsage = async (
hass: HomeAssistant,
disk = "default",
maxDepth?: number
) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
endpoint: `/host/disks/${disk}/usage`,
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
...(maxDepth === undefined ? {} : { params: { max_depth: maxDepth } }),
});
-67
View File
@@ -45,73 +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" | "unknown";
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;
ssid?: string | null;
bssid?: string | null;
host_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;
-22
View File
@@ -1,7 +1,6 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import { callWS } from "../util/websocket";
import type { DeviceRegistryEntry } from "./device/device_registry";
export enum InclusionState {
/** The controller isn't doing anything regarding inclusion. */
@@ -466,27 +465,6 @@ export interface RequestedGrant {
clientSideAuth: boolean;
}
/**
* Get the Z-Wave node ID of a device from its registry identifiers, which have
* the form `<home id>-<node id>[-<manufacturer>:<product type>:<product id>]`.
* Returns undefined for devices without a node, e.g. provisioning entries.
*/
export const getNodeIdFromDevice = (
device: DeviceRegistryEntry
): number | undefined => {
for (const [domain, identifier] of device.identifiers) {
// a provisioning entry is identified by its DSK, whose blocks parse as numbers
if (domain !== "zwave_js" || identifier.startsWith("provision_")) {
continue;
}
const nodeId = parseInt(identifier.split("-")[1]);
if (!isNaN(nodeId)) {
return nodeId;
}
}
return undefined;
};
export const invokeZWaveCCApi = <T = unknown>(
hass: HomeAssistant,
device_id: string,
+44 -13
View File
@@ -34,7 +34,6 @@ import "../components/ha-dialog-footer";
import "../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
import "../components/ha-dropdown-item";
import "../components/ha-filter-pane-chip";
import "../components/ha-icon-button";
import "../components/ha-svg-icon";
import "../components/input/ha-input-search";
@@ -238,13 +237,20 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
const localize = this.localizeFunc || this.hass.localize;
const showPane = this._showPaneController.value ?? !this.narrow;
const filterButton = this.hasFilters
? html`<ha-filter-pane-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.path=${mdiFilterVariant}
.count=${this.filters}
.active=${!!this.filters}
@click=${this._toggleFilters}
></ha-filter-pane-chip>`
? html`<div class="relative">
<ha-assist-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.active=${this.filters}
@click=${this._toggleFilters}
>
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
</ha-assist-chip>
${
this.filters
? html`<div class="badge">${this.filters}</div>`
: nothing
}
</div>`
: nothing;
const selectModeBtn =
@@ -465,14 +471,18 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? nothing
: html`<div class="pane" slot="pane">
<div class="table-header">
<ha-filter-pane-chip
<ha-assist-chip
.label=${localize(
"ui.components.subpage-data-table.filters"
)}
.path=${mdiFilterVariant}
active
@click=${this._toggleFilters}
></ha-filter-pane-chip>
>
<ha-svg-icon
slot="icon"
.path=${mdiFilterVariant}
></ha-svg-icon>
</ha-assist-chip>
${
this.filters
? html`<ha-icon-button
@@ -875,6 +885,24 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
padding: 16px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.narrow-header-row {
display: flex;
align-items: center;
@@ -923,8 +951,11 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
gap: var(--ha-space-2);
}
ha-assist-chip,
ha-filter-pane-chip {
.relative {
position: relative;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
--ha-assist-chip-container-color: var(--card-background-color);
}
@@ -49,7 +49,6 @@ const SUPPORTED_UI_TYPES = [
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
identify: () => false,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
@@ -124,7 +124,7 @@ class HaBackupConfigData extends LitElement {
private async _fetchStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass);
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
} catch (_err: any) {
this._storageInfo = null;
}
@@ -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,398 +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;
// "unknown" (no measurement, link presumed up) sits above "none"/dead so it
// reads as a present link, not a degraded one
case "unknown":
return 2;
default:
return 1;
}
};
// links are colored by transport; signal level stays on the line width.
// Both hues clear 3:1 on the light and the dark card background -- named
// palette colors have no dark variant, so a darker pick would vanish.
export const networkToColorVar = (network?: string | null): string => {
switch (network) {
case "thread":
return "--purple-color";
case "wifi":
return "--pink-color";
// `network` is a plain string on the wire: "ethernet" and anything a newer
// server invents still draw, just neutrally
default:
return "--secondary-text-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 (
// many vendors report an identical vendor/model pair on every unit
node.host_name ||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
hass.localize("ui.panel.config.matter.visualization.border_router")
);
}
if (node.kind === "wifi_ap") {
return (
// the SSID names the network; network_name still holds the BSSID here
node.ssid ||
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, hass.devices)
: undefined;
const name = getTopologyNodeName(node, hass);
// an AP is named by its SSID, so its own radio address is what tells two
// radios of one mesh apart; everything else is named by its network
const networkLabel =
node.kind === "wifi_ap"
? node.bssid || node.network_name
: node.ssid || node.network_name;
const contextParts: string[] = [];
if (area) {
contextParts.push(area.name);
}
// skip a label that just repeats the name, e.g. an AP with no SSID
if ((multiNetwork || !area) && networkLabel && networkLabel !== name) {
contextParts.push(networkLabel);
}
nodes.push({
id: node.id,
name,
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,
// a dead link keeps its own color: "none", "weak" and "unknown" all
// collapse to width 1, so nothing else would tell them apart
color: style.getPropertyValue(
conn.strength === "none"
? "--disabled-color"
: networkToColorVar(conn.network)
),
type:
oneWay || asymmetric
? "dashed"
: !forward && conn.via_route_table
? "dotted"
: "solid",
},
ignoreForceLayout: !(
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
),
});
});
// HA to a hub is a real path; HA to a hubless component's representative only
// means "reachable, exact position unknown", so that variant is drawn dotted.
// It keeps the HA node's own color rather than a transport hue: it is a
// logical reachability edge, not a radio link.
// `symbol: "none"` is what keeps the arrowhead off these edges -- ha-network-graph
// keys arrow suppression on `reverseValue`, not on `value` -- so it must stay.
const haLink = (
target: string,
type: "solid" | "dotted" = "solid"
): NetworkLink => ({
source: HOME_ASSISTANT_NODE_ID,
target,
value: 0,
symbol: "none",
lineStyle: {
width: 3,
color: categoryColors[CATEGORY_HOME_ASSISTANT],
type,
},
});
// 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 -- except a group of only "unknown" Thread neighbours,
// which are not commissioned on our fabric, so HA has no path to claim
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 representative = component
.filter((id) => nodeCategories.get(id) !== CATEGORY_UNKNOWN)
.sort(
(a, b) => (adjacency.get(b)?.size ?? 0) - (adjacency.get(a)?.size ?? 0)
)[0];
if (representative) {
links.push(haLink(representative, "dotted"));
}
});
// 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,508 +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,
getTopologyNodeName,
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);
return node ? getTopologyNodeName(node, this.hass) : 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);
}
if (node.host_name) {
attributes.push(node.host_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[] = [];
// the link color now encodes the transport, and the graph legend can
// only describe nodes, so name it here
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this._localizeDynamic("network_type", conn.network)}`
);
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 (!conn.source_to_target && !conn.target_to_source) {
// no per-direction reading: state the overall strength the width is
// drawn from, so this edge class is not left unexplained
const details = [
this._localizeDynamic("visualization.strength", conn.strength),
];
if (conn.via_route_table) {
details.push(
this.hass.localize(
"ui.panel.config.matter.visualization.via_route_table"
)
);
}
lines.push(html`<br />${details.join(" • ")}`);
}
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, this.hass.devices)
: 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;
}
}
@@ -2,7 +2,6 @@ import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -20,13 +19,11 @@ import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type {
RssiError,
ZWaveJSNodeStatisticsUpdatedMessage,
ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js";
import {
fetchZwaveNetworkStatus,
getNodeIdFromDevice,
NodeStatus,
subscribeZwaveNodeStatistics,
} from "../../../../../data/zwave_js";
@@ -57,36 +54,19 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _searchFilter = "";
// Route statistics reference repeaters by device registry ID
private _nodeIdsByDeviceId: Record<string, number> = {};
public hassSubscribe() {
const subscriptions: Promise<UnsubscribeFunc>[] = [];
const devices: Record<number, DeviceRegistryEntry> = {};
const nodeIdsByDeviceId: Record<string, number> = {};
const devices = Object.values(this.hass.devices).filter((device) =>
device.config_entries.some((entry) => entry === this.configEntryId)
);
Object.values(this.hass.devices).forEach((device) => {
if (!device.config_entries.includes(this.configEntryId)) {
return;
}
const nodeId = getNodeIdFromDevice(device);
if (nodeId === undefined) {
return;
}
devices[nodeId] = device;
nodeIdsByDeviceId[device.id] = nodeId;
subscriptions.push(
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
this._nodeStatistics[nodeId] = message;
this._handleUpdatedNodeStatistics();
})
);
});
this._nodeIdsByDeviceId = nodeIdsByDeviceId;
this._devices = devices;
return subscriptions;
return devices.map((device) =>
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
const nodeId = message.nodeId ?? message.node_id;
this._devices[nodeId!] = device;
this._nodeStatistics[nodeId!] = message;
this._handleUpdatedNodeStatistics();
})
);
}
public connectedCallback() {
@@ -184,10 +164,8 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
// links point away from the controller, so the route belongs to the target
const stats =
this._nodeStatistics[target] ?? this._nodeStatistics[source];
const route = stats?.lwr || stats?.nlwr;
const route =
this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr;
return html`${sourceName}
${targetName}${
route?.protocol_data_rate
@@ -355,69 +333,55 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
});
});
if (controllerNode === undefined) {
return { nodes, links, categories };
}
const controllerId = String(controllerNode);
Object.entries(nodeStatistics).forEach(([nodeId, stats]) => {
const route = stats.lwr || stats.nlwr;
if (!route) {
return;
if (route) {
const hops = [
...route.repeaters.map((id, i) => [
Object.keys(this._devices).find(
(_nodeId) => this._devices[_nodeId]?.id === id
)?.[0],
route.repeater_rssi[i],
]),
[controllerNode!, route.rssi],
];
let sourceNode: string = nodeId;
hops.forEach(([repeater, rssi]) => {
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) =>
link.source === sourceNode && link.target === String(repeater)
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target: String(repeater),
value: RSSI,
lineStyle: {
width,
color:
repeater === controllerNode
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = String(repeater);
});
}
// Routes go from the controller to the node via the repeaters, in order.
// Each station measures the hop leaving it: the controller reports
// `rssi`, repeater i reports `repeater_rssi[i]`.
const hops: [string, RssiError | number | null][] = [];
let hopRssi = route.rssi;
route.repeaters.forEach((deviceId, i) => {
const repeaterNodeId = this._nodeIdsByDeviceId[deviceId];
// skip repeaters we can't resolve, so the chain stays connected
if (repeaterNodeId !== undefined) {
hops.push([String(repeaterNodeId), hopRssi]);
}
hopRssi = route.repeater_rssi[i];
});
hops.push([nodeId, hopRssi]);
let sourceNode = controllerId;
hops.forEach(([target, rssi]) => {
if (target === sourceNode) {
return;
}
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) => link.source === sourceNode && link.target === target
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target,
value: RSSI,
lineStyle: {
width,
color:
sourceNode === controllerId
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = target;
});
});
return { nodes, links, categories };
@@ -203,28 +203,9 @@ export const showRepairsFlowDialog = (
return "";
},
renderCreateEntryDescription(hass, step) {
const description = hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.create_entry.${step.description || "default"}`,
step.description_placeholders
);
renderCreateEntryDescription(hass, _step) {
return html`
${
description
? html`
<ha-markdown
allow-svg
breaks
.content=${description}
></ha-markdown>
`
: html`<p>
${hass.localize("ui.dialogs.repair_flow.success.description")}
</p>`
}
<p>${hass.localize("ui.dialogs.repair_flow.success.description")}</p>
`;
},
@@ -310,17 +291,11 @@ export const showRepairsFlowDialog = (
},
renderMenuOption(hass, step, option) {
return (
hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
mergePlaceholders(issue, step)
) ||
// Newer backends can offer options this frontend has no
// translation for yet — show the raw option key instead of
// an empty menu entry
option
return hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
mergePlaceholders(issue, step)
);
},
@@ -9,10 +9,12 @@ import {
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { navigate } from "../../../common/navigate";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
@@ -20,6 +22,7 @@ import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-segmented-bar";
import type { Segment } from "../../../components/ha-segmented-bar";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHostInfo, HostDisksUsage } from "../../../data/hassio/host";
@@ -41,6 +44,7 @@ import {
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { bytesToString } from "../../../util/bytes-to-string";
import "../core/ha-config-analytics";
import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk";
import { showMountViewDialog } from "./show-dialog-view-mount";
@@ -62,6 +66,13 @@ class HaConfigSectionStorage extends LitElement {
@state() private _mountsInfo?: SupervisorMounts | null;
// Keyed by mount name. A missing key means the request is still in flight;
// null means it failed, and that row simply shows no usage.
@state() private _mountUsage: Record<string, HostDisksUsage | null> = {};
// Guards against a slow response from a previous reload landing in a newer one.
private _mountUsageGeneration = 0;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass.config, "hassio")) {
@@ -173,6 +184,7 @@ class HaConfigSectionStorage extends LitElement {
graphic="avatar"
.mount=${mount}
twoline
multiline-secondary
hasMeta
@click=${this._changeMount}
>
@@ -193,13 +205,16 @@ class HaConfigSectionStorage extends LitElement {
${mount.name}
</span>
<span slot="secondary">
${mount.server}${
mount.port ? `:${mount.port}` : nothing
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
<span class="mount-address">
${mount.server}${
mount.port ? `:${mount.port}` : ""
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
</span>
${this._renderMountUsage(mount)}
</span>
${
mount.state !== SupervisorMountState.ACTIVE
@@ -289,6 +304,39 @@ class HaConfigSectionStorage extends LitElement {
`;
}
private _renderMountUsage(mount: SupervisorMount) {
if (mount.state !== SupervisorMountState.ACTIVE) {
return nothing;
}
if (!(mount.name in this._mountUsage)) {
return html`<div class="mount-usage">
<ha-spinner size="tiny"></ha-spinner>
</div>`;
}
const usage = this._mountUsage[mount.name];
// Without a total there is no ratio to show, so show nothing rather than a
// bar that means something else.
if (!usage?.total_bytes) {
return nothing;
}
const percent = (usage.used_bytes / usage.total_bytes) * 100;
return html`<div class="mount-usage">
<ha-bar
class=${classMap({
"target-warning": percent > 85,
"target-critical": percent > 95,
})}
.value=${percent}
></ha-bar>
<span>
${this.hass.localize("ui.panel.config.storage.detailed_description", {
used: bytesToString(usage.used_bytes),
total: bytesToString(usage.total_bytes),
})}
</span>
</div>`;
}
private async _load() {
this._loadStorageInfo();
try {
@@ -305,7 +353,7 @@ class HaConfigSectionStorage extends LitElement {
private async _loadStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass);
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
} catch (err: any) {
this._error = err.message || err;
this._storageInfo = null;
@@ -361,6 +409,34 @@ class HaConfigSectionStorage extends LitElement {
this._error = err.message || err;
this._mountsInfo = null;
}
this._loadMountUsage();
}
// Deliberately not awaited: a mount on a slow or unreachable server can take
// ~30 s to answer, and the rows must paint before then. Only active mounts are
// asked, since the endpoint has nothing to report for the others.
private _loadMountUsage(): void {
const generation = ++this._mountUsageGeneration;
this._mountUsage = {};
this._mountsInfo?.mounts
.filter((mount) => mount.state === SupervisorMountState.ACTIVE)
.forEach((mount) => {
fetchHostDisksUsage(this.hass, mount.name).then(
(usage) => this._setMountUsage(generation, mount.name, usage),
() => this._setMountUsage(generation, mount.name, null)
);
});
}
private _setMountUsage(
generation: number,
name: string,
usage: HostDisksUsage | null
): void {
if (generation !== this._mountUsageGeneration) {
return;
}
this._mountUsage = { ...this._mountUsage, [name]: usage };
}
static styles = css`
@@ -409,6 +485,41 @@ class HaConfigSectionStorage extends LitElement {
color: var(--warning-color);
}
/* multiline-secondary lets the secondary slot wrap, so the address keeps its
own single ellipsized line and only the usage sits below it. */
.mount-address {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mount-usage {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-top: var(--ha-space-1);
}
.mount-usage ha-bar {
flex: 0 0 72px;
--ha-bar-primary-color: var(--metric-bar-ok-color, var(--success-color));
}
.mount-usage ha-bar.target-warning {
--ha-bar-primary-color: var(
--metric-bar-warning-color,
var(--warning-color)
);
}
.mount-usage ha-bar.target-critical {
--ha-bar-primary-color: var(
--metric-bar-critical-color,
var(--error-color)
);
}
.mounts-not-supported {
padding: 0 16px 16px;
}
@@ -11,11 +11,6 @@ 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
// Readable capacity of the default 400px card: ~18px per node including the
// 6px nodeGap. Past this, bars collapse toward the 1px minimum and the labels
// shrink with them.
export const DEFAULT_MAX_SANKEY_DEVICES = 20;
// 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 };
@@ -41,103 +36,6 @@ export const fireSankeyNodeMoreInfo = (el: HTMLElement, node: Node): void => {
}
};
export interface FindDevicesOverCapOptions {
devices: DeviceConsumptionEnergyPreference[];
/** Max named children per parent. Falsy or non-positive disables the cap. */
maxDevices: number | undefined;
rootNodeId: string;
renderedIds: ReadonlySet<string>;
deviceValues: ReadonlyMap<string, number>;
getId: (device: DeviceConsumptionEnergyPreference) => string | undefined;
/** First ancestor rendered as its own node, else undefined. */
getEffectiveParent: (
device: DeviceConsumptionEnergyPreference
) => string | undefined;
}
/**
* Node ids to drop from the rendered set because their parent has more rendered
* children than `maxDevices`. Callers group the result into the parent's "Other"
* node, so the smallest devices merge instead of disappearing.
*/
export const findDevicesOverCap = ({
devices,
maxDevices,
rootNodeId,
renderedIds,
deviceValues,
getId,
getEffectiveParent,
}: FindDevicesOverCapOptions): Set<string> => {
const grouped = new Set<string>();
if (!maxDevices || !Number.isFinite(maxDevices) || maxDevices <= 0) {
return grouped;
}
const childrenByParent = new Map<
string,
{ id: string; value: number; idx: number }[]
>();
const seenIds = new Set<string>();
devices.forEach((device, idx) => {
const id = getId(device);
if (!id || !renderedIds.has(id) || seenIds.has(id)) {
return;
}
seenIds.add(id);
const parentKey = getEffectiveParent(device) ?? rootNodeId;
if (!childrenByParent.has(parentKey)) {
childrenByParent.set(parentKey, []);
}
childrenByParent.get(parentKey)!.push({
id,
value: deviceValues.get(id)!,
idx,
});
});
// Taking the whole subtree stops a grouped device's children from re-parenting
// upward while its value, which already includes them, rolls into "Other".
const groupSubtree = (id: string): void => {
if (grouped.has(id)) {
return;
}
grouped.add(id);
childrenByParent.get(id)?.forEach((child) => groupSubtree(child.id));
};
const queue = [rootNodeId];
const visited = new Set(queue);
while (queue.length) {
const children = childrenByParent.get(queue.shift()!);
if (!children) {
continue;
}
let kept = children;
if (children.length > maxDevices) {
// Raw value includes children, so a parent never sorts below its own
// child; declaration order breaks ties so the layout is stable.
const ranked = [...children].sort(
(a, b) => a.value - b.value || a.idx - b.idx
);
// At least two, because a lone grouped device is rendered by name instead
// and would void the cap.
ranked
.slice(0, Math.max(children.length - maxDevices, 2))
.forEach((child) => groupSubtree(child.id));
kept = children.filter((child) => !grouped.has(child.id));
}
kept.forEach((child) => {
if (!visited.has(child.id)) {
visited.add(child.id);
queue.push(child.id);
}
});
}
return grouped;
};
export interface BuildSankeyDeviceNodesOptions {
devices: DeviceConsumptionEnergyPreference[];
computedStyle: CSSStyleDeclaration;
@@ -146,12 +44,6 @@ export interface BuildSankeyDeviceNodesOptions {
rootNodeId: string;
/** Flows below this value are grouped into an "Other" node. */
minThreshold: number;
/**
* Max named child nodes per parent. Once a parent has more rendered children
* than this, the smallest are grouped into that parent's "Other" node.
* Undefined means no cap.
*/
maxDevices?: number;
/** Per-parent untracked residual only shown when above this value. */
untrackedFloor: number;
/** Round the "Other" node value up (instantaneous cards only). */
@@ -184,7 +76,6 @@ export const buildSankeyDeviceNodes = (
localize,
rootNodeId,
minThreshold,
maxDevices,
untrackedFloor,
ceilOtherValue,
initialUntracked,
@@ -255,19 +146,6 @@ export const buildSankeyDeviceNodes = (
return undefined;
};
// The value threshold only catches devices that are a negligible share of the
// home total, so a panel of dozens of similar-sized circuits never groups.
findDevicesOverCap({
devices,
maxDevices,
rootNodeId,
renderedIds,
deviceValues,
getId,
getEffectiveParent: (device) =>
findEffectiveParent(device.included_in_stat),
}).forEach((id) => renderedIds.delete(id));
devices.forEach((device, idx) => {
const id = getId(device);
if (!id) {
@@ -276,9 +154,8 @@ export const buildSankeyDeviceNodes = (
const value = deviceValues.get(id)!;
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (!renderedIds.has(id)) {
// Below the value threshold or demoted by the count cap. Collect it
// instead of folding it into untracked.
if (value < minThreshold) {
// Collect small consumers instead of folding them into untracked
const parentKey = effectiveParent ?? rootNodeId;
if (!smallConsumersByParent.has(parentKey)) {
smallConsumersByParent.set(parentKey, []);
@@ -30,7 +30,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "./common/sankey";
@@ -299,7 +298,6 @@ class HuiEnergySankeyCard
localize: this.hass.localize,
rootNodeId: "home",
minThreshold: minEnergyThreshold,
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
untrackedFloor: 0,
ceilOtherValue: false,
initialUntracked: homeNode.value,
@@ -23,7 +23,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "./common/sankey";
@@ -298,7 +297,6 @@ class HuiPowerSankeyCard
localize: this.hass.localize,
rootNodeId: "home",
minThreshold: minPowerThreshold,
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
untrackedFloor: 1,
ceilOtherValue: true,
initialUntracked: homeNode.value,
-1
View File
@@ -174,7 +174,6 @@ export interface EnergyCardSankeyConfig extends EnergyCardConfig {
layout?: "auto" | "vertical" | "horizontal";
group_by_floor?: boolean;
group_by_area?: boolean;
max_devices?: number;
}
export interface EnergyDateSelectorCardConfig extends EnergyCardBaseConfig {
@@ -22,7 +22,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "../energy/common/sankey";
@@ -261,7 +260,6 @@ class HuiWaterFlowSankeyCard
localize: this.hass.localize,
rootNodeId,
minThreshold: minFlowThreshold,
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
untrackedFloor: 1,
ceilOtherValue: true,
initialUntracked: effectiveTotalInflow,
@@ -27,7 +27,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "../energy/common/sankey";
@@ -242,7 +241,6 @@ class HuiWaterSankeyCard
localize: this.hass.localize,
rootNodeId: "home",
minThreshold: minWaterThreshold,
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
untrackedFloor: 0,
ceilOtherValue: false,
initialUntracked: homeNode.value,
@@ -5,7 +5,6 @@ import {
assign,
boolean,
literal,
number,
object,
optional,
string,
@@ -37,7 +36,6 @@ const cardConfigStruct = assign(
),
group_by_floor: optional(boolean()),
group_by_area: optional(boolean()),
max_devices: optional(number()),
})
);
@@ -94,11 +92,6 @@ export class HuiEnergySankeyCardEditor
},
],
},
{
name: "max_devices",
required: false,
selector: { number: { min: 1, mode: "box" } },
},
{
type: "string",
name: "collection_key",
@@ -141,10 +134,6 @@ export class HuiEnergySankeyCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.collection_key_description`
);
case "max_devices":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.energy-sankey.max_devices_description`
);
default:
return undefined;
}
@@ -155,7 +144,6 @@ export class HuiEnergySankeyCardEditor
case "layout":
case "group_by_floor":
case "group_by_area":
case "max_devices":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.energy-sankey.${schema.name}`
);
-45
View File
@@ -8422,7 +8422,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}",
@@ -8471,48 +8470,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",
"unknown": "Unknown"
},
"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",
@@ -10037,8 +9994,6 @@
"description": "This card shows the distribution of energy in your home on a Sankey graph.",
"group_by_floor": "Group by floor",
"group_by_area": "Group by area",
"max_devices": "Maximum devices per parent",
"max_devices_description": "Devices beyond this limit are grouped into an \"Other\" node, smallest first. The limit applies per upstream device, not per floor or area. Defaults to 20.",
"layout": "Graph direction",
"layout_directions": {
"auto": "Automatic",
-63
View File
@@ -1,63 +0,0 @@
import { describe, it, expect } from "vitest";
import { getNodeIdFromDevice } from "../../src/data/zwave_js";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
const mockDevice = (identifiers: [string, string][]) =>
({ identifiers }) as DeviceRegistryEntry;
describe("getNodeIdFromDevice", () => {
it("reads the node ID from the identifier", () => {
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-25"]]))
).toBe(25);
});
it("reads the node ID from the extended identifier", () => {
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-25-798:5:20"]]))
).toBe(25);
});
it("returns multi-digit node IDs in full", () => {
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-108"]]))
).toBe(108);
});
it("ignores provisioning entries, whose DSK blocks parse as numbers", () => {
expect(
getNodeIdFromDevice(
mockDevice([
[
"zwave_js",
"provision_50285-00042-09924-30691-15973-33711-04005-03623",
],
])
)
).toBeUndefined();
});
it("ignores identifiers of other integrations", () => {
expect(
getNodeIdFromDevice(mockDevice([["mqtt", "3245146787-25"]]))
).toBeUndefined();
});
it("picks the Z-Wave identifier when a device has several", () => {
expect(
getNodeIdFromDevice(
mockDevice([
["matter", "some-other-id"],
["zwave_js", "3245146787-14"],
])
)
).toBe(14);
});
it("returns undefined when there is no node ID", () => {
expect(getNodeIdFromDevice(mockDevice([]))).toBeUndefined();
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787"]]))
).toBeUndefined();
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { fetchHostDisksUsage } from "../../src/data/hassio/host";
import type { HomeAssistant } from "../../src/types";
const mockHass = () => {
const callWS = vi.fn().mockResolvedValue({
id: "root",
label: "Total",
total_bytes: 2000398934016,
used_bytes: 1240247081779,
});
return { hass: { callWS } as unknown as HomeAssistant, callWS };
};
const sentMessage = (callWS: ReturnType<typeof vi.fn>) =>
callWS.mock.calls[0][0];
describe("fetchHostDisksUsage", () => {
it("targets the data disk and sends no depth by default", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass);
expect(callWS).toHaveBeenCalledWith({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600,
});
});
it("addresses a mount by name", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas");
expect(sentMessage(callWS).endpoint).toBe("/host/disks/media_nas/usage");
});
// Walking a mount costs a round trip per directory, so the row fetch must let
// the Supervisor apply its own per-target default rather than pinning one.
it("omits max_depth for a mount", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas");
expect(sentMessage(callWS)).not.toHaveProperty("params");
});
it("sends max_depth only when a caller asks for it", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "default", 3);
expect(sentMessage(callWS).params).toEqual({ max_depth: 3 });
});
it("sends max_depth 0 when explicitly asked, rather than dropping it", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas", 0);
expect(sentMessage(callWS).params).toEqual({ max_depth: 0 });
});
it("returns the usage tree", async () => {
const { hass } = mockHass();
await expect(fetchHostDisksUsage(hass, "media_nas")).resolves.toMatchObject(
{
total_bytes: 2000398934016,
used_bytes: 1240247081779,
}
);
});
});
@@ -1,725 +0,0 @@
import { describe, expect, it } from "vitest";
import type {
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
} from "../../../../../src/data/matter";
import {
createMatterNetworkChartData,
getTopologyNodeCategory,
getTopologyNodeName,
networkToColorVar,
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);
// jsdom resolves custom properties set inline, so color lookups can be
// asserted on real values; use a fresh element so nothing leaks into the
// tests that expect the bare element's empty strings
const themedElement = (): HTMLElement => {
const el = document.createElement("div");
el.style.setProperty("--primary-color", "#009ac7");
el.style.setProperty("--purple-color", "#926bc7");
el.style.setProperty("--pink-color", "#e91e63");
el.style.setProperty("--disabled-color", "#bdbdbd");
document.body.appendChild(el);
return el;
};
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);
});
it("renders an unmeasured link as present, above a dead one", () => {
// "unknown" (no measurement) must not collapse to the "none"/dead bucket
expect(strengthToScale("unknown")).toBe(2);
expect(strengthToScale("unknown")).toBeGreaterThan(strengthToScale("none"));
});
});
describe("networkToColorVar", () => {
it("separates the two transports and degrades gracefully", () => {
expect(networkToColorVar("thread")).toBe("--purple-color");
expect(networkToColorVar("wifi")).toBe("--pink-color");
expect(networkToColorVar("thread")).not.toBe(networkToColorVar("wifi"));
// the wire type is a plain string, not a union
expect(networkToColorVar("ethernet")).toBe("--secondary-text-color");
expect(networkToColorVar(undefined)).toBe("--secondary-text-color");
});
});
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");
});
it("prefers the border router hostname over its generic vendor and model", () => {
// some vendors report the same vendor/model on every unit they ship, so
// vendor+model alone labels every border router identically
expect(
getTopologyNodeName(
node({
id: "br_1",
kind: "border_router",
host_name: "Cuisine",
vendor_name: "Apple",
model_name: "BorderRouter",
}),
mockHass()
)
).toBe("Cuisine");
});
it("keeps the HA device name ahead of the border router hostname", () => {
expect(
getTopologyNodeName(
node({
id: "br_1",
kind: "border_router",
ha_device_id: "dev1",
host_name: "Cuisine",
}),
mockHass({ dev1: { name: "Kitchen hub" } })
)
).toBe("Kitchen hub");
});
it("falls through to vendor and model when host_name is null", () => {
// core's serializer always emits the key, so null must behave as absent
expect(
getTopologyNodeName(
node({
id: "br_1",
kind: "border_router",
host_name: null,
vendor_name: "Apple",
model_name: "BorderRouter",
}),
mockHass()
)
).toBe("Apple BorderRouter");
});
it("names a Wi-Fi access point by its SSID, not its radio address", () => {
expect(
getTopologyNodeName(
node({
id: "ap_1",
kind: "wifi_ap",
network_type: "wifi",
ssid: "we@home",
network_name: "50:91:00:D9:62:00",
}),
mockHass()
)
).toBe("we@home");
});
it("falls back to the BSSID when the server sends no SSID", () => {
expect(
getTopologyNodeName(
node({
id: "ap_1",
kind: "wifi_ap",
network_type: "wifi",
ssid: null,
network_name: "50:91:00:D9:62:00",
}),
mockHass()
)
).toBe("50:91:00:D9:62:00");
});
});
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("shows an access point's radio address beside its SSID, and never twice", () => {
const named = createMatterNetworkChartData(
topology([
node({
id: "ap_1",
kind: "wifi_ap",
network_type: "wifi",
ssid: "we@home",
bssid: "50:91:00:D9:62:00",
network_name: "50:91:00:D9:62:00",
}),
]),
mockHass(),
element
);
const ap = named.nodes.find((n) => n.id === "ap_1")!;
// the radio address is what distinguishes two radios of one mesh
expect(ap.name).toBe("we@home");
expect(ap.context).toBe("50:91:00:D9:62:00");
const unnamed = createMatterNetworkChartData(
topology([
node({
id: "ap_1",
kind: "wifi_ap",
network_type: "wifi",
network_name: "50:91:00:D9:62:00",
}),
]),
mockHass(),
element
);
const bare = unnamed.nodes.find((n) => n.id === "ap_1")!;
// without an SSID the name is already the address, so no context repeat
expect(bare.name).toBe("50:91:00:D9:62:00");
expect(bare.context).toBeUndefined();
});
it("leaves a component of only unknown neighbours unanchored", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "unknown_1", kind: "thread_unknown" }),
node({ id: "unknown_2", kind: "thread_unknown" }),
],
[connection({ source: "unknown_1", target: "unknown_2" })]
),
mockHass(),
element
);
// an unknown neighbour is not commissioned on our fabric, so HA has no
// operational path to it and must not draw one
const haTargets = data.links
.filter((l) => l.source === "ha")
.map((l) => l.target);
expect(haTargets).toEqual(["1"]);
});
it("anchors an unknown neighbour through its known peer, not through HA", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "unknown_1", kind: "thread_unknown" }),
],
[connection({ source: "1", target: "unknown_1" })]
),
mockHass(),
element
);
// floating the unknowns must not mean dropping them out of a mixed group
const haTargets = data.links
.filter((l) => l.source === "ha")
.map((l) => l.target);
expect(haTargets).toEqual(["1"]);
expect(data.nodes.find((n) => n.id === "unknown_1")).toBeDefined();
});
it("draws a lone unknown neighbour with no links at all", () => {
const data = createMatterNetworkChartData(
topology([node({ id: "unknown_1", kind: "thread_unknown" })]),
mockHass(),
element
);
expect(data.links).toHaveLength(0);
// polarDistance is what places an unlinked node in ha-network-graph;
// at 0 it would stack on the origin instead
const unknown = data.nodes.find((n) => n.id === "unknown_1")!;
expect(unknown.polarDistance).toBe(0.8);
});
it("colors links by transport, not by signal level", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "br_1", kind: "border_router" }),
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "ap_1", kind: "wifi_ap", network_type: "wifi" }),
node({ id: "7", node_id: 7, network_type: "wifi", role: "station" }),
],
[
connection({ source: "1", target: "br_1", strength: "weak" }),
connection({
source: "7",
target: "ap_1",
network: "wifi",
strength: "strong",
}),
]
),
mockHass(),
themedElement()
);
// a weak thread link and a strong wi-fi link differ by transport in the
// color channel and by level in the width channel
const threadLink = data.links.find((l) => l.source === "1")!;
const wifiLink = data.links.find((l) => l.source === "7")!;
expect(threadLink.lineStyle?.color).toBe("#926bc7");
expect(threadLink.lineStyle?.width).toBe(1);
expect(wifiLink.lineStyle?.color).toBe("#e91e63");
expect(wifiLink.lineStyle?.width).toBe(3);
// the HA spine is reachability, not a radio link, and keeps the HA color
const spine = data.links.find((l) => l.source === "ha")!;
expect(spine.lineStyle?.color).toBe("#009ac7");
});
it("keeps a dead link grey so it cannot pass for a healthy one", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "br_1", kind: "border_router" }),
node({ id: "1", node_id: 1, role: "router" }),
],
[connection({ source: "1", target: "br_1", strength: "none" })]
),
mockHass(),
themedElement()
);
// width cannot carry this: "none", "weak" and "unknown" are all width 1
const link = data.links.find((l) => l.source === "1")!;
expect(link.lineStyle?.color).toBe("#bdbdbd");
});
it("draws the hub path solid and the position-unknown anchor dotted", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "br_1", kind: "border_router", host_name: "Cuisine" }),
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "9", node_id: 9, role: "router" }),
],
[connection({ source: "1", target: "br_1" })]
),
mockHass(),
element
);
// HA -> border router is a real path
const hubLink = data.links.find(
(l) => l.source === "ha" && l.target === "br_1"
)!;
expect(hubLink.lineStyle?.type).toBe("solid");
// HA -> a hubless component's representative only means "reachable,
// position unknown"
const orphanLink = data.links.find(
(l) => l.source === "ha" && l.target === "9"
)!;
expect(orphanLink.lineStyle?.type).toBe("dotted");
// symbol, not the falsy value, is what keeps the arrowhead off
expect(orphanLink.symbol).toBe("none");
expect(orphanLink.reverseValue).toBeUndefined();
// the wire host_name reaches the rendered label, not just the helper
expect(data.nodes.find((n) => n.id === "br_1")!.name).toBe("Cuisine");
});
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,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../../../src/types";
import type * as HostModule from "../../../../src/data/hassio/host";
import type * as MountsModule from "../../../../src/data/supervisor/mounts";
import {
fetchHassioHostInfo,
fetchHostDisksUsage,
} from "../../../../src/data/hassio/host";
import {
fetchSupervisorMounts,
SupervisorMountState,
SupervisorMountType,
SupervisorMountUsage,
} from "../../../../src/data/supervisor/mounts";
import "../../../../src/panels/config/storage/ha-config-section-storage";
// Heavy children are irrelevant here and drag in echarts / ResizeObserver.
vi.mock("../../../../src/panels/config/storage/storage-breakdown-chart", () => {
customElements.define(
"storage-breakdown-chart",
class extends HTMLElement {}
);
return {};
});
vi.mock("../../../../src/layouts/hass-subpage", () => {
customElements.define("hass-subpage", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/panels/config/core/ha-config-analytics", () => ({}));
vi.mock("../../../../src/components/ha-segmented-bar", () => {
customElements.define("ha-segmented-bar", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-list", () => {
customElements.define("ha-list", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-list-item", () => {
customElements.define("ha-list-item", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-card", () => {
customElements.define("ha-card", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-alert", () => {
customElements.define("ha-alert", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-icon-button", () => {
customElements.define("ha-icon-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-icon-next", () => {
customElements.define("ha-icon-next", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-svg-icon", () => {
customElements.define("ha-svg-icon", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-bar", () => {
customElements.define("ha-bar", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-spinner", () => {
customElements.define("ha-spinner", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/data/hassio/host", async (importOriginal) => ({
...(await importOriginal<typeof HostModule>()),
fetchHassioHostInfo: vi.fn(),
fetchHostDisksUsage: vi.fn(),
}));
vi.mock("../../../../src/data/supervisor/mounts", async (importOriginal) => ({
...(await importOriginal<typeof MountsModule>()),
fetchSupervisorMounts: vi.fn(),
reloadSupervisorMount: vi.fn(),
}));
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
const deferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const mount = (name: string, state: SupervisorMountState) => ({
name,
usage: SupervisorMountUsage.MEDIA,
type: SupervisorMountType.CIFS,
server: `${name}.local`,
port: 445,
share: "media",
state,
});
const hass = {
localize: (key: string, params?: Record<string, unknown>) =>
key === "ui.panel.config.storage.detailed_description"
? `${params!.used} of ${params!.total} used`
: key,
config: { components: ["hassio"] },
// _renderDiskLifeTime formats a percentage, which needs the locale.
locale: { language: "en" },
} as unknown as HomeAssistant;
const nextTask = () =>
new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const rows = (el: HTMLElement) =>
Array.from(el.shadowRoot!.querySelectorAll("ha-list-item"));
const rowFor = (el: HTMLElement, name: string) =>
rows(el).find((row) => row.textContent?.includes(name));
describe("ha-config-section-storage per-mount usage", () => {
let usageCalls: Record<string, Deferred<any>>;
beforeEach(() => {
usageCalls = {};
vi.mocked(fetchHassioHostInfo).mockResolvedValue({
features: ["mount"],
disk_life_time: 5,
} as any);
vi.mocked(fetchSupervisorMounts).mockResolvedValue({
default_backup_mount: null,
mounts: [
mount("alpha", SupervisorMountState.ACTIVE),
mount("beta", SupervisorMountState.ACTIVE),
mount("broken", SupervisorMountState.FAILED),
],
} as any);
vi.mocked(fetchHostDisksUsage).mockImplementation(
(_hass, disk = "default") => {
// The panel's own disk-metrics call must not interfere with the row calls.
if (disk === "default") {
return new Promise(() => {
// never settles
});
}
usageCalls[disk] = deferred<any>();
return usageCalls[disk].promise;
}
);
});
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
const render = async () => {
const el = document.createElement("ha-config-section-storage");
(el as any).hass = hass;
document.body.append(el);
await nextTask();
await (el as any).updateComplete;
return el as HTMLElement;
};
it("asks only active mounts for usage", async () => {
await render();
const asked = vi
.mocked(fetchHostDisksUsage)
.mock.calls.map((call) => call[1])
.filter((disk) => disk !== "default");
expect(asked).toEqual(["alpha", "beta"]);
expect(asked).not.toContain("broken");
});
it("renders the rows before any usage arrives", async () => {
const el = await render();
expect(rows(el)).toHaveLength(3);
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).not.toBeNull();
});
it("fills in each row as its own request settles", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
await nextTask();
await (el as any).updateComplete;
expect(rowFor(el, "alpha")!.textContent).toContain(
"500 Bytes of 1000 Bytes used"
);
// beta has not answered yet, so it must still be pending, not blanked.
expect(rowFor(el, "beta")!.querySelector("ha-spinner")).not.toBeNull();
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).toBeNull();
});
it("leaves a row without usage when its request fails, and keeps the others", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
usageCalls.beta.reject(new Error("dead server"));
await nextTask();
await (el as any).updateComplete;
const beta = rowFor(el, "beta")!;
expect(beta.querySelector("ha-spinner")).toBeNull();
expect(beta.querySelector("ha-bar")).toBeNull();
expect(beta.textContent).not.toContain("used");
expect(rowFor(el, "alpha")!.querySelector("ha-bar")).not.toBeNull();
});
it("never shows usage for a mount that is not active", async () => {
const el = await render();
const broken = rowFor(el, "broken")!;
expect(broken.querySelector("ha-spinner")).toBeNull();
expect(broken.querySelector("ha-bar")).toBeNull();
});
});
@@ -6,8 +6,6 @@ import type {
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
findDevicesOverCap,
getSankeyDeviceSections,
groupSankeyDevicesByFloorAndArea,
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
@@ -83,86 +81,6 @@ describe("getSankeyDeviceSections", () => {
});
});
describe("findDevicesOverCap", () => {
// Values keyed by node id, hierarchy by included_in_stat.
const overCapOpts = (
values: Record<string, number>,
parents: Record<string, string>,
// No default: passing `undefined` must mean "no cap", not "fall back to 3".
maxDevices: number | undefined,
rendered = Object.keys(values)
) => {
const list: DeviceConsumptionEnergyPreference[] = Object.keys(values).map(
(id) => ({ stat_consumption: id, included_in_stat: parents[id] })
);
const renderedIds = new Set(rendered);
return {
devices: list,
maxDevices,
rootNodeId: "home",
renderedIds,
deviceValues: new Map(Object.entries(values)),
getId: (device: DeviceConsumptionEnergyPreference) =>
device.stat_consumption,
getEffectiveParent: (device: DeviceConsumptionEnergyPreference) => {
let current = device.included_in_stat;
for (let hops = 0; current && hops < list.length; hops++) {
if (renderedIds.has(current)) {
return current;
}
current = parents[current];
}
return undefined;
},
};
};
it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
"groups nothing for a cap of %s",
(maxDevices) => {
expect(
findDevicesOverCap(
overCapOpts({ a: 5, b: 4, c: 3, d: 2 }, {}, maxDevices)
)
).toEqual(new Set());
}
);
it("groups the smallest children of an over-cap parent", () => {
expect(
findDevicesOverCap(overCapOpts({ a: 5, b: 4, c: 3, d: 2, e: 1 }, {}, 3))
).toEqual(new Set(["d", "e"]));
});
it("groups a whole subtree, not just the parent", () => {
expect(
findDevicesOverCap(
overCapOpts(
{ a: 50, b: 40, c: 30, small: 10, child: 9 },
{ child: "small" },
3
)
)
).toEqual(new Set(["small", "child", "c"]));
});
it("counts only rendered devices", () => {
// four devices, three rendered, so a cap of three does not fire
expect(
findDevicesOverCap(
overCapOpts({ a: 5, b: 4, c: 3, tiny: 0.001 }, {}, 3, ["a", "b", "c"])
)
).toEqual(new Set());
});
it("terminates on a cyclic parent chain", () => {
// both devices parent each other, so neither is reachable from the root
expect(
findDevicesOverCap(overCapOpts({ a: 5, b: 4 }, { a: "b", b: "a" }, 1))
).toEqual(new Set());
});
});
describe("buildSankeyDeviceNodes", () => {
it("renders top-level devices and subtracts them from untracked", () => {
const result = buildSankeyDeviceNodes(
@@ -505,385 +423,6 @@ describe("buildSankeyDeviceNodes", () => {
expect(result.deviceNodes).toEqual([]);
expect(result.untrackedConsumption).toBe(10);
});
describe("device count cap", () => {
// Five devices all comfortably above minThreshold, so only the count cap
// can group any of them.
const fiveDevices = () =>
devices(
{ stat_consumption: "a" },
{ stat_consumption: "b" },
{ stat_consumption: "c" },
{ stat_consumption: "d" },
{ stat_consumption: "e" }
);
const fiveValues = { a: 50, b: 40, c: 30, d: 20, e: 10 };
const namedIds = (result: { deviceNodes: SankeyDeviceNode[] }) =>
result.deviceNodes
.filter(
(n) => !n.id.startsWith("other_") && !n.id.startsWith("untracked_")
)
.map((n) => n.id);
it("does not group anything when maxDevices is unset", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: fiveDevices(),
values: fiveValues,
initialUntracked: 150,
})
);
expect(namedIds(result)).toEqual(["a", "b", "c", "d", "e"]);
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
false
);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("does nothing at exactly the cap", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "a" },
{ stat_consumption: "b" },
{ stat_consumption: "c" }
),
values: { a: 50, b: 40, c: 30 },
maxDevices: 3,
initialUntracked: 120,
})
);
expect(namedIds(result)).toEqual(["a", "b", "c"]);
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
false
);
});
it("groups the smallest above the cap, keeping the cap many by name", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: fiveDevices(),
values: fiveValues,
maxDevices: 3,
initialUntracked: 150,
})
);
// the cap budgets named devices; Other is overhead on top
expect(namedIds(result)).toEqual(["a", "b", "c"]);
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBeCloseTo(30, 10);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("never demotes exactly one device, which would render it by name", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "a" },
{ stat_consumption: "b" },
{ stat_consumption: "c" },
{ stat_consumption: "d" }
),
values: { a: 40, b: 30, c: 20, d: 10 },
maxDevices: 3,
initialUntracked: 100,
})
);
expect(namedIds(result)).toEqual(["a", "b"]);
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBeCloseTo(30, 10);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("demotes a whole subtree so children never re-parent or double count", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "g" },
{ stat_consumption: "a", included_in_stat: "g" },
{ stat_consumption: "c", included_in_stat: "a" },
{ stat_consumption: "b1", included_in_stat: "g" },
{ stat_consumption: "b2", included_in_stat: "g" },
{ stat_consumption: "b3", included_in_stat: "g" },
{ stat_consumption: "b4", included_in_stat: "g" }
),
values: { g: 100, a: 10, c: 9, b1: 30, b2: 25, b3: 20, b4: 8 },
maxDevices: 3,
initialUntracked: 100,
})
);
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"g",
"b1",
"b2",
"b3",
"other_g",
"untracked_g",
]);
// a's value already contains c, so Other is 10 + 8 and not 27
const other = result.deviceNodes.find((n) => n.id === "other_g");
expect(other?.value).toBeCloseTo(18, 10);
expect(result.deviceNodes.some((n) => n.id === "c")).toBe(false);
const untracked = result.deviceNodes.find((n) => n.id === "untracked_g");
expect(untracked?.value).toBeCloseTo(7, 10);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("ranks by raw value, so a parent is never demoted before its own child", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "d" },
{ stat_consumption: "e", included_in_stat: "d" },
{ stat_consumption: "f" },
{ stat_consumption: "h" },
{ stat_consumption: "i" }
),
values: { d: 50, e: 45, f: 10, h: 40, i: 30 },
maxDevices: 3,
initialUntracked: 130,
})
);
// Ranking on value-excluding-children would demote d (50 - 45 = 5) while
// keeping e, counting e both on its own and inside d's rolled-up value.
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"d",
"e",
"h",
"other_home",
"untracked_d",
]);
expect(result.parentLinks.e).toBe("d");
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBeCloseTo(40, 10);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("applies the cap per parent and links Other to that parent", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p" },
{ stat_consumption: "c1", included_in_stat: "p" },
{ stat_consumption: "c2", included_in_stat: "p" },
{ stat_consumption: "c3", included_in_stat: "p" },
{ stat_consumption: "c4", included_in_stat: "p" },
{ stat_consumption: "c5", included_in_stat: "p" },
{ stat_consumption: "r1" },
{ stat_consumption: "r2" }
),
values: {
p: 100,
c1: 30,
c2: 25,
c3: 20,
c4: 15,
c5: 10,
r1: 50,
r2: 40,
},
maxDevices: 3,
initialUntracked: 190,
})
);
// p is over the cap; the three root-level nodes are exactly at it
expect(namedIds(result)).toEqual(["p", "c1", "c2", "c3", "r1", "r2"]);
const other = result.deviceNodes.find((n) => n.id === "other_p");
expect(other?.value).toBeCloseTo(25, 10);
expect(result.parentLinks.other_p).toBe("p");
expect(result.links).toContainEqual({ source: "p", target: "other_p" });
expect(result.deviceNodes.some((n) => n.id === "other_home")).toBe(false);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("counts only rendered children, not ones already below the threshold", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p" },
{ stat_consumption: "c1", included_in_stat: "p" },
{ stat_consumption: "c2", included_in_stat: "p" },
{ stat_consumption: "c3", included_in_stat: "p" },
{ stat_consumption: "s1", included_in_stat: "p" },
{ stat_consumption: "s2", included_in_stat: "p" },
{ stat_consumption: "s3", included_in_stat: "p" }
),
values: {
p: 100,
c1: 30,
c2: 25,
c3: 20,
s1: 0.001,
s2: 0.002,
s3: 0.003,
},
maxDevices: 3,
initialUntracked: 100,
})
);
// three rendered children is at the cap, so no demotion happens
expect(namedIds(result)).toEqual(["p", "c1", "c2", "c3"]);
const other = result.deviceNodes.find((n) => n.id === "other_p");
expect(other?.value).toBeCloseTo(0.006, 10);
});
it("terminates on a cyclic included_in_stat and leaves it unchanged", () => {
const cyclic = () =>
cumulativeOpts({
devices: devices(
{ stat_consumption: "a", included_in_stat: "b" },
{ stat_consumption: "b", included_in_stat: "a" }
),
values: { a: 5, b: 4 },
initialUntracked: 10,
});
// A cycle member always has a rendered parent inside the cycle, so it is
// never reachable from the root and the cap cannot touch it.
expect(
buildSankeyDeviceNodes({ ...cyclic(), maxDevices: 1 })
).toStrictEqual(buildSankeyDeviceNodes(cyclic()));
});
it("treats a zero or non-finite cap as no cap", () => {
[0, Number.NaN, Number.POSITIVE_INFINITY].forEach((maxDevices) => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: fiveDevices(),
values: fiveValues,
maxDevices,
initialUntracked: 150,
})
);
expect(namedIds(result)).toEqual(["a", "b", "c", "d", "e"]);
});
});
it("does not count devices without a node id (instantaneous cards)", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "a", stat_rate: "ra" },
{ stat_consumption: "b", stat_rate: "rb" },
{ stat_consumption: "c", stat_rate: "rc" },
{ stat_consumption: "d" }
),
values: { ra: 30, rb: 25, rc: 20 },
getId: (device) => device.stat_rate,
maxDevices: 3,
initialUntracked: 75,
})
);
// d has no stat_rate, so the three rendered nodes are at the cap
expect(namedIds(result)).toEqual(["ra", "rb", "rc"]);
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
false
);
});
it("ceils the capped Other value but still subtracts the raw total", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: fiveDevices(),
values: { ...fiveValues, e: 10.5 },
maxDevices: 3,
ceilOtherValue: true,
initialUntracked: 150.5,
})
);
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBe(31);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("breaks value ties on declaration order, stably across runs", () => {
const opts = () =>
cumulativeOpts({
devices: fiveDevices(),
values: { a: 50, b: 20, c: 20, d: 20, e: 10 },
maxDevices: 3,
initialUntracked: 120,
});
expect(namedIds(buildSankeyDeviceNodes(opts()))).toEqual(["a", "c", "d"]);
expect(namedIds(buildSankeyDeviceNodes(opts()))).toEqual(["a", "c", "d"]);
});
// The documented promise is "more than 20 devices start grouping", so pin
// the boundary against the real default rather than a test-local cap.
it.each([
[DEFAULT_MAX_SANKEY_DEVICES, DEFAULT_MAX_SANKEY_DEVICES, false],
// one over the cap still demotes two, because a lone demoted device would
// be rendered by name and void the cap
[DEFAULT_MAX_SANKEY_DEVICES + 1, DEFAULT_MAX_SANKEY_DEVICES - 1, true],
[DEFAULT_MAX_SANKEY_DEVICES + 2, DEFAULT_MAX_SANKEY_DEVICES, true],
[DEFAULT_MAX_SANKEY_DEVICES + 12, DEFAULT_MAX_SANKEY_DEVICES, true],
])(
"with %i devices at the default cap renders %i named nodes (grouped: %s)",
(deviceCount, expectedNamed, expectOther) => {
const list = Array.from({ length: deviceCount }, (_, i) => ({
stat_consumption: `d${i}`,
}));
const values = Object.fromEntries(
list.map((d, i) => [d.stat_consumption, 10 + i])
);
const total = Object.values(values).reduce((s, v) => s + v, 0);
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: list,
values,
maxDevices: DEFAULT_MAX_SANKEY_DEVICES,
initialUntracked: total,
})
);
expect(namedIds(result)).toHaveLength(expectedNamed);
expect(result.deviceNodes.some((n) => n.id === "other_home")).toBe(
expectOther
);
// grouping never changes the total that comes off the root
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
}
);
it("keeps 32 circuit clamps readable at the default cap", () => {
const clamps = Array.from({ length: 32 }, (_, i) => ({
stat_consumption: `clamp_${i}`,
}));
const values = Object.fromEntries(
clamps.map((clamp, i) => [clamp.stat_consumption, 20 + i])
);
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: clamps,
values,
maxDevices: DEFAULT_MAX_SANKEY_DEVICES,
initialUntracked: 1200,
})
);
expect(namedIds(result)).toHaveLength(DEFAULT_MAX_SANKEY_DEVICES);
expect(result.deviceNodes).toHaveLength(DEFAULT_MAX_SANKEY_DEVICES + 1);
// the 12 smallest clamps, values 20 through 31
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBeCloseTo(306, 10);
// flow conservation: every device node plus untracked accounts for home
const { nodes } = buildSankeyLayout({
hass: createMockHass(),
computedStyle,
localize: mockLocalize,
deviceNodes: result.deviceNodes,
parentLinks: result.parentLinks,
rootNodeId: "home",
groupByFloor: false,
groupByArea: false,
untrackedConsumption: result.untrackedConsumption,
untrackedFloor: 0,
});
const deviceTotal = nodes
.filter((n) => n.index === 4)
.reduce((sum, n) => sum + n.value, 0);
expect(deviceTotal).toBeCloseTo(1200, 10);
});
});
});
describe("groupSankeyDevicesByFloorAndArea", () => {
+5 -5
View File
@@ -10022,7 +10022,7 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.13"
js-yaml: "npm:5.3.0"
js-yaml: "npm:5.2.3"
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
@@ -10956,14 +10956,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.3.0":
version: 5.3.0
resolution: "js-yaml@npm:5.3.0"
"js-yaml@npm:5.2.3":
version: 5.2.3
resolution: "js-yaml@npm:5.2.3"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/6c7e8ea8dd5643d9df73f4aa796f3572537284e9b819b74ef919467452bfa4624f8f7477cfc775c91db7e2f32387133b1b6c4e95aef9702be6bb042ae554884a
checksum: 10/5d2562f2d7e7bc51bd678b43d2dbc3965129ac854222c794157369c8904d249cfc78325dff8fc64becb505a2506242548c54c8fa50cdb24554bb1a557e460004
languageName: node
linkType: hard