Compare commits

...

3 Commits

Author SHA1 Message Date
Petar Petrov f9d8a86905 Refine Matter graph HA node: theme-aware memoization, drop dead branch 2026-07-23 10:38:57 +03:00
Petar Petrov 0f75e1074b Add central Home Assistant node to Matter network graph 2026-07-23 10:24:13 +03:00
Petar Petrov 300b34f876 Add Matter network topology visualization page 2026-07-23 09:44:11 +03:00
7 changed files with 1428 additions and 0 deletions
+63
View File
@@ -45,6 +45,69 @@ export interface MatterNodeDiagnostics {
export type MatterPingResult = Record<string, boolean>;
export type MatterTopologyNodeKind =
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
export type MatterTopologyStrength = "strong" | "medium" | "weak" | "none";
export interface MatterTopologyDirectionInfo {
strength: MatterTopologyStrength;
lqi?: number | null;
rssi?: number | null;
}
export interface MatterNetworkTopologyNode {
id: string;
kind: MatterTopologyNodeKind;
network_type: string;
node_id?: number | null;
ha_device_id?: string | null;
role?: string | null;
available?: boolean | null;
is_bridge?: boolean | null;
ext_address?: string | null;
rloc16?: number | null;
ext_pan_id?: string | null;
network_name?: string | null;
vendor_name?: string | null;
model_name?: string | null;
last_seen?: number | null;
}
export interface MatterNetworkTopologyConnection {
source: string;
target: string;
network: string;
strength: MatterTopologyStrength;
source_to_target?: MatterTopologyDirectionInfo | null;
target_to_source?: MatterTopologyDirectionInfo | null;
via_route_table?: boolean | null;
path_cost?: number | null;
}
export interface MatterNetworkTopology {
collected_at: number;
nodes: MatterNetworkTopologyNode[];
connections: MatterNetworkTopologyConnection[];
}
export const fetchMatterNetworkTopology = (
hass: HomeAssistant,
refresh = false
): Promise<MatterNetworkTopology> =>
hass.callWS({
type: "matter/network_topology",
refresh,
});
export const subscribeMatterNetworkTopology = (
hass: HomeAssistant,
callback: (topology: MatterNetworkTopology) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
type: "matter/subscribe_network_topology",
});
export interface MatterCommissioningParameters {
setup_pin_code: number;
setup_manual_code: string;
@@ -5,6 +5,7 @@ import {
mdiPlus,
mdiShape,
mdiTune,
mdiVectorPolyline,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -144,6 +145,10 @@ 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>
@@ -252,6 +257,9 @@ 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,6 +27,10 @@ class MatterConfigRouter extends HassRouterPage {
tag: "matter-options-page",
load: () => import("./matter-options-page"),
},
visualization: {
tag: "matter-network-visualization",
load: () => import("./matter-network-visualization"),
},
},
};
@@ -0,0 +1,363 @@
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import type {
NetworkData,
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import type {
MatterNetworkTopology,
MatterNetworkTopologyNode,
MatterTopologyStrength,
} from "../../../../../data/matter";
import type { HomeAssistant } from "../../../../../types";
const CATEGORY_HOME_ASSISTANT = 0;
const CATEGORY_BORDER_ROUTER = 1;
const CATEGORY_ROUTER = 2;
const CATEGORY_END_DEVICE = 3;
const CATEGORY_WIFI_AP = 4;
const CATEGORY_OFFLINE = 5;
const CATEGORY_UNKNOWN = 6;
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
// HA is not a Matter node; the frontend synthesizes it as the graph root.
export const HOME_ASSISTANT_NODE_ID = "ha";
const HOME_ASSISTANT_LABEL = "Home Assistant";
// 0 is never returned: a falsy link value re-enables the direction arrow
// in ha-network-graph
export const strengthToScale = (
strength?: MatterTopologyStrength | null
): number => {
switch (strength) {
case "strong":
return 4;
case "medium":
return 3;
case "weak":
return 2;
default:
return 1;
}
};
export const strengthToColorVar = (
strength?: MatterTopologyStrength | null
): string => {
switch (strength) {
case "strong":
return "--success-color";
case "medium":
return "--warning-color";
case "weak":
return "--error-color";
default:
return "--disabled-color";
}
};
const strengthToWidth = (strength?: MatterTopologyStrength | null): number =>
strength === "strong" ? 3 : strength === "medium" ? 2 : 1;
export const getTopologyNodeCategory = (
node: MatterNetworkTopologyNode
): number => {
if (node.kind === "border_router") {
return CATEGORY_BORDER_ROUTER;
}
if (node.kind === "wifi_ap") {
return CATEGORY_WIFI_AP;
}
if (node.kind === "thread_unknown") {
return CATEGORY_UNKNOWN;
}
if (node.available === false) {
return CATEGORY_OFFLINE;
}
return node.role && ROUTER_ROLES.has(node.role)
? CATEGORY_ROUTER
: CATEGORY_END_DEVICE;
};
export const getTopologyNodeName = (
node: MatterNetworkTopologyNode,
hass: HomeAssistant
): string => {
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
if (device) {
return device.name_by_user || device.name || node.id;
}
if (node.kind === "border_router") {
return (
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
hass.localize("ui.panel.config.matter.visualization.border_router")
);
}
if (node.kind === "wifi_ap") {
return (
node.network_name ||
hass.localize("ui.panel.config.matter.visualization.wifi_ap")
);
}
if (node.kind === "thread_unknown") {
return hass.localize("ui.panel.config.matter.visualization.unknown_device");
}
if (node.node_id != null) {
return hass.localize("ui.panel.config.matter.visualization.node", {
node_id: node.node_id,
});
}
return node.id;
};
const isHub = (category: number): boolean =>
category === CATEGORY_BORDER_ROUTER || category === CATEGORY_WIFI_AP;
export function createMatterNetworkChartData(
topology: MatterNetworkTopology,
hass: HomeAssistant,
element: Element
): NetworkData {
const style = getComputedStyle(element);
const categoryColors = [
style.getPropertyValue("--primary-color"),
style.getPropertyValue("--deep-purple-color"),
style.getPropertyValue("--cyan-color"),
style.getPropertyValue("--teal-color"),
style.getPropertyValue("--indigo-color"),
style.getPropertyValue("--error-color"),
style.getPropertyValue("--disabled-color"),
];
const categories = [
{
name: HOME_ASSISTANT_LABEL,
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.border_router"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_BORDER_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.router"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.end_device"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_END_DEVICE] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.wifi_ap"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_WIFI_AP] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.offline"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_OFFLINE] },
},
{
name: hass.localize(
"ui.panel.config.matter.visualization.unknown_devices"
),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
},
];
const threadNetworks = new Set(
topology.nodes.map((node) => node.ext_pan_id).filter(Boolean)
);
const multiNetwork = threadNetworks.size > 1;
const nodes: NetworkNode[] = [
{
id: HOME_ASSISTANT_NODE_ID,
name: HOME_ASSISTANT_LABEL,
category: CATEGORY_HOME_ASSISTANT,
value: 4,
symbol: "roundRect",
symbolSize: 45,
polarDistance: 0,
fixed: true,
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
];
const nodeCategories = new Map<string, number>();
topology.nodes.forEach((node) => {
const category = getTopologyNodeCategory(node);
nodeCategories.set(node.id, category);
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
const area = device ? getDeviceArea(device, hass.areas) : undefined;
const contextParts: string[] = [];
if (area) {
contextParts.push(area.name);
}
if ((multiNetwork || !area) && node.network_name) {
contextParts.push(node.network_name);
}
nodes.push({
id: node.id,
name: getTopologyNodeName(node, hass),
context: contextParts.join(" • ") || undefined,
category,
value: isHub(category) ? 3 : category === CATEGORY_ROUTER ? 2 : 1,
symbol: isHub(category) ? "roundRect" : "circle",
symbolSize: isHub(category) ? 40 : category === CATEGORY_ROUTER ? 30 : 20,
itemStyle: {
color: categoryColors[category],
...(node.role === "leader"
? {
borderColor: style.getPropertyValue("--primary-color"),
borderWidth: 2,
}
: {}),
},
polarDistance: isHub(category)
? 0.1
: category === CATEGORY_ROUTER
? 0.4
: 0.8,
});
});
const links: NetworkLink[] = [];
topology.connections.forEach((conn) => {
if (!nodeCategories.has(conn.source) || !nodeCategories.has(conn.target)) {
return;
}
let { source, target } = conn;
let forward = conn.source_to_target;
let reverse = conn.target_to_source;
if (!forward && reverse) {
// normalize so the arrow points in the observed direction
[source, target] = [target, source];
forward = reverse;
reverse = undefined;
}
const oneWay = Boolean(forward) && !reverse;
const asymmetric =
forward && reverse && forward.strength !== reverse.strength;
const width = strengthToWidth(conn.strength);
links.push({
source,
target,
value: strengthToScale(forward?.strength ?? conn.strength),
// route-table edges without per-direction info are not directional
reverseValue: oneWay
? undefined
: strengthToScale(reverse?.strength ?? conn.strength),
symbolSize: oneWay ? width * 2 + 3 : undefined,
lineStyle: {
width,
color: style.getPropertyValue(strengthToColorVar(conn.strength)),
type:
oneWay || asymmetric
? "dashed"
: !forward && conn.via_route_table
? "dotted"
: "solid",
},
ignoreForceLayout: !(
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
),
});
});
const haLink = (target: string): NetworkLink => ({
source: HOME_ASSISTANT_NODE_ID,
target,
value: 0,
symbol: "none",
lineStyle: {
width: 3,
color: categoryColors[CATEGORY_HOME_ASSISTANT],
},
});
// HA reaches the mesh through the border routers and Wi-Fi access points
const hubIds = topology.nodes
.filter((node) => node.kind === "border_router" || node.kind === "wifi_ap")
.map((node) => node.id);
hubIds.forEach((id) => links.push(haLink(id)));
// any node group without a border router / AP is linked straight to HA so
// it never floats free (HA has a direct operational path to every node)
const adjacency = new Map<string, Set<string>>();
topology.nodes.forEach((node) => adjacency.set(node.id, new Set()));
links.forEach((link) => {
if (link.source === HOME_ASSISTANT_NODE_ID) {
return;
}
adjacency.get(link.source)?.add(link.target);
adjacency.get(link.target)?.add(link.source);
});
const hubIdSet = new Set(hubIds);
const visited = new Set<string>();
topology.nodes.forEach((startNode) => {
if (visited.has(startNode.id)) {
return;
}
const component: string[] = [];
const queue = [startNode.id];
visited.add(startNode.id);
while (queue.length) {
const id = queue.shift()!;
component.push(id);
adjacency.get(id)?.forEach((next) => {
if (!visited.has(next)) {
visited.add(next);
queue.push(next);
}
});
}
if (component.some((id) => hubIdSet.has(id))) {
return;
}
const candidates = component.filter(
(id) => nodeCategories.get(id) !== CATEGORY_UNKNOWN
);
const representative = (candidates.length ? candidates : component).sort(
(a, b) => (adjacency.get(b)?.size ?? 0) - (adjacency.get(a)?.size ?? 0)
)[0];
if (representative) {
links.push(haLink(representative));
}
});
// keep the strongest link of every node in the force layout so
// nodes hang near their best connection instead of floating free
nodes.forEach((node) => {
let bestLink: NetworkLink | undefined;
const hasActiveLink = links.some((link) => {
if (link.source !== node.id && link.target !== node.id) {
return false;
}
if (!link.ignoreForceLayout) {
return true;
}
const linkValue = Math.max(link.value ?? 0, link.reverseValue ?? 0);
if (
linkValue >
Math.max(bestLink?.value ?? -1, bestLink?.reverseValue ?? -1)
) {
bestLink = link;
}
return false;
});
if (!hasActiveLink && bestLink) {
bestLink.ignoreForceLayout = false;
}
});
return { nodes, links, categories };
}
@@ -0,0 +1,515 @@
import { mdiRefresh } from "@mdi/js";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import { throttle } from "../../../../../common/util/throttle";
import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type {
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
MatterTopologyDirectionInfo,
} from "../../../../../data/matter";
import {
fetchMatterNetworkTopology,
subscribeMatterNetworkTopology,
} from "../../../../../data/matter";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import {
createMatterNetworkChartData,
HOME_ASSISTANT_NODE_ID,
} from "./matter-network-data";
const UPDATE_THROTTLE_TIME = 5000;
@customElement("matter-network-visualization")
export class MatterNetworkVisualization extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public route!: Route;
@state() private _topology?: MatterNetworkTopology;
@state() private _notSupported = false;
@state() private _error?: string;
@state() private _refreshing = false;
@state() private _searchFilter = "";
private _unsub?: Promise<UnsubscribeFunc>;
private _throttledUpdateTopology = throttle(
(topology: MatterNetworkTopology) => {
this._topology = topology;
},
UPDATE_THROTTLE_TIME
);
public connectedCallback(): void {
super.connectedCallback();
if (this.hass && !this._unsub) {
this._subscribe();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._throttledUpdateTopology.cancel();
if (this._unsub) {
this._unsub.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
}
}
private _subscribe(): void {
this._unsub = subscribeMatterNetworkTopology(this.hass, (topology) => {
if (!this._topology) {
this._topology = topology;
} else {
this._throttledUpdateTopology(topology);
}
});
this._unsub.catch((err: { code?: string; message?: string }) => {
this._unsub = undefined;
if (err?.code === "not_supported" || err?.code === "unknown_command") {
this._notSupported = true;
} else {
this._error = err?.message || String(err);
}
});
}
protected render() {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.matter.visualization.header"
)}
back-path="/config/matter/dashboard"
>
${
this.narrow && this._topology?.nodes.length
? html`<div slot="header">${this._renderInputSearch()}</div>`
: nothing
}
${this._renderContent()}
</hass-subpage>
`;
}
private _renderContent() {
if (this._notSupported) {
return html`<div class="center">
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.matter.visualization.not_supported"
)}
</ha-alert>
</div>`;
}
if (this._error) {
return html`<div class="center">
<ha-alert alert-type="error">
${this.hass.localize(
"ui.panel.config.matter.visualization.error_loading",
{ error: this._error }
)}
</ha-alert>
</div>`;
}
if (!this._topology) {
return html`<div class="center"><ha-spinner></ha-spinner></div>`;
}
if (!this._topology.nodes.length) {
return html`<div class="center empty">
${this.hass.localize("ui.panel.config.matter.visualization.empty")}
</div>`;
}
return html`
<ha-network-graph
.hass=${this.hass}
.searchFilter=${this._searchFilter}
.data=${this._formatNetworkData(
this._topology,
this.hass.devices,
this.hass.areas,
this.hass.themes,
this.hass.language
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow ? this._renderInputSearch("search") : nothing}
<ha-icon-button
slot="button"
class="refresh-button"
.disabled=${this._refreshing}
.path=${mdiRefresh}
@click=${this._refreshTopology}
label=${this.hass.localize(
"ui.panel.config.matter.visualization.refresh_topology"
)}
></ha-icon-button>
</ha-network-graph>
`;
}
private _renderInputSearch(slot = "") {
return html`<ha-input-search
appearance="outlined"
slot=${slot}
.value=${this._searchFilter}
@input=${this._handleSearchChange}
></ha-input-search>`;
}
private _handleSearchChange(ev: InputEvent): void {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private async _refreshTopology(): Promise<void> {
if (this._refreshing) {
return;
}
this._refreshing = true;
try {
this._topology = await fetchMatterNetworkTopology(this.hass, true);
} catch (err: unknown) {
this._error = (err as { message?: string })?.message || String(err);
} finally {
this._refreshing = false;
}
}
private _formatNetworkData = memoizeOne(
(
topology: MatterNetworkTopology,
_devices: HomeAssistant["devices"],
_areas: HomeAssistant["areas"],
// node/link colors and labels also depend on the theme and language,
// so both take part in the cache key even though they are read via hass
_themes: HomeAssistant["themes"],
_language: HomeAssistant["language"]
) => createMatterNetworkChartData(topology, this.hass, this)
);
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
return this._topology?.nodes.find((node) => node.id === id);
}
private _getConnection(
source: string,
target: string
): MatterNetworkTopologyConnection | undefined {
return this._topology?.connections.find(
(conn) =>
(conn.source === source && conn.target === target) ||
(conn.source === target && conn.target === source)
);
}
private _getNodeName(id: string): string {
const node = this._getTopologyNode(id);
if (!node) {
return id;
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
if (device) {
return device.name_by_user || device.name || id;
}
if (node.kind === "border_router") {
return (
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
this.hass.localize("ui.panel.config.matter.visualization.border_router")
);
}
if (node.kind === "wifi_ap") {
return (
node.network_name ||
this.hass.localize("ui.panel.config.matter.visualization.wifi_ap")
);
}
if (node.kind === "thread_unknown") {
return this.hass.localize(
"ui.panel.config.matter.visualization.unknown_device"
);
}
if (node.node_id != null) {
return this.hass.localize("ui.panel.config.matter.visualization.node", {
node_id: node.node_id,
});
}
return id;
}
private _getSearchableAttributes = (nodeId: string): string[] => {
const node = this._getTopologyNode(nodeId);
if (!node) {
return [];
}
const attributes: string[] = [];
if (node.node_id != null) {
attributes.push(String(node.node_id));
}
if (node.network_name) {
attributes.push(node.network_name);
}
if (node.ext_address) {
attributes.push(node.ext_address);
}
if (node.vendor_name) {
attributes.push(node.vendor_name);
}
if (node.model_name) {
attributes.push(node.model_name);
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
if (device?.manufacturer) {
attributes.push(device.manufacturer);
}
if (device?.model) {
attributes.push(device.model);
}
device?.connections.forEach((connection) => {
attributes.push(connection[1]);
});
return attributes;
};
private _localizeDynamic(prefix: string, value: string): string {
return (
this.hass.localize(
`ui.panel.config.matter.${prefix}.${value}` as LocalizeKeys
) || value
);
}
private _formatDirection(direction: MatterTopologyDirectionInfo): string {
const strength = this._localizeDynamic(
"visualization.strength",
direction.strength
);
if (direction.lqi != null) {
return `${strength} (LQI ${direction.lqi})`;
}
if (direction.rssi != null) {
return `${strength} (RSSI ${direction.rssi} dBm)`;
}
return strength;
}
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target } = data as { source: string; target: string };
const conn = this._getConnection(source, target);
if (!conn) {
return nothing;
}
const lines: TemplateResult[] = [];
if (conn.source_to_target) {
lines.push(
html`<br />${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}:
${this._formatDirection(conn.source_to_target)}`
);
}
if (conn.target_to_source) {
lines.push(
html`<br />${this._getNodeName(conn.target)}
${this._getNodeName(conn.source)}:
${this._formatDirection(conn.target_to_source)}`
);
}
if (!lines.length && conn.via_route_table) {
lines.push(
html`<br />${this.hass.localize(
"ui.panel.config.matter.visualization.via_route_table"
)}`
);
}
return html`<b
>${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}</b
>${lines}`;
}
const { id } = data as { id: string };
if (id === HOME_ASSISTANT_NODE_ID) {
return html`<b>Home Assistant</b>`;
}
const node = this._getTopologyNode(id);
if (!node) {
return nothing;
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const lines: TemplateResult[] = [];
if (node.node_id != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.node_id"
)}:</b
>
${node.node_id}`
);
}
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this._localizeDynamic("network_type", node.network_type)}${
node.network_name ? html` (${node.network_name})` : nothing
}`
);
if (node.role) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.role"
)}:</b
>
${this._localizeDynamic("visualization.roles", node.role)}`
);
}
if (node.available != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.status"
)}:</b
>
${this.hass.localize(
node.available
? "ui.panel.config.matter.visualization.online"
: "ui.panel.config.matter.visualization.offline"
)}`
);
}
if (device?.manufacturer || node.vendor_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.manufacturer"
)}:</b
>
${device?.manufacturer || node.vendor_name}`
);
}
if (device?.model || node.model_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.model"
)}:</b
>
${device?.model || node.model_name}`
);
}
if (area) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.area"
)}:</b
>
${area.name}`
);
}
if (node.last_seen != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.last_seen"
)}:</b
>
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
);
}
return html`<b>${this._getNodeName(id)}</b>${lines}`;
};
private _handleChartClick(e: CustomEvent): void {
if (
e.detail.dataType === "node" &&
e.detail.event.target.cursor === "pointer"
) {
const { id } = e.detail.data;
const node = this._getTopologyNode(id);
if (node?.ha_device_id) {
navigate(`/config/devices/device/${node.ha_device_id}`);
}
}
}
static get styles(): CSSResultGroup {
return [
css`
ha-network-graph {
height: 100%;
}
[slot="header"] {
display: flex;
align-items: center;
}
ha-input-search {
flex: 1;
}
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--ha-space-4);
box-sizing: border-box;
}
ha-alert {
max-width: 500px;
}
.empty {
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"matter-network-visualization": MatterNetworkVisualization;
}
}
+42
View File
@@ -8328,6 +8328,7 @@
"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}",
@@ -8376,6 +8377,47 @@
}
}
},
"visualization": {
"header": "Network visualization",
"refresh_topology": "Refresh topology",
"border_router": "Border router",
"router": "Router",
"end_device": "End device",
"wifi_ap": "Wi-Fi access point",
"offline": "Offline",
"online": "Online",
"unknown_device": "Unknown device",
"unknown_devices": "Unknown devices",
"node": "Node {node_id}",
"node_id": "Node ID",
"network": "Network",
"role": "Role",
"status": "Status",
"manufacturer": "Manufacturer",
"model": "Model",
"area": "Area",
"last_seen": "Last seen",
"via_route_table": "Learned from routing table",
"empty": "No network topology data is available yet.",
"not_supported": "The connected Matter server does not support network topology. Update your Matter server to use this feature.",
"error_loading": "Failed to load the network topology: {error}",
"strength": {
"strong": "Strong",
"medium": "Medium",
"weak": "Weak",
"none": "None"
},
"roles": {
"leader": "Leader",
"router": "Router",
"reed": "Router-eligible end device",
"end_device": "End device",
"sleepy_end_device": "Sleepy end device",
"unassigned": "Unassigned",
"station": "Station",
"ap": "Access point"
}
},
"network_type": {
"thread": "Thread",
"wifi": "Wi-Fi",
@@ -0,0 +1,433 @@
import { describe, expect, it } from "vitest";
import type {
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
} from "../../../../../src/data/matter";
import {
createMatterNetworkChartData,
getTopologyNodeCategory,
getTopologyNodeName,
strengthToScale,
} from "../../../../../src/panels/config/integrations/integration-panels/matter/matter-network-data";
import type { HomeAssistant } from "../../../../../src/types";
const mockHass = (
devices: Record<string, Partial<HomeAssistant["devices"][string]>> = {},
areas: Record<string, Partial<HomeAssistant["areas"][string]>> = {}
): HomeAssistant =>
({
localize: (key: string) => key.split(".").pop(),
devices,
areas,
}) as unknown as HomeAssistant;
const node = (
overrides: Partial<MatterNetworkTopologyNode> & { id: string }
): MatterNetworkTopologyNode => ({
kind: "matter",
network_type: "thread",
...overrides,
});
const connection = (
overrides: Partial<MatterNetworkTopologyConnection> & {
source: string;
target: string;
}
): MatterNetworkTopologyConnection => ({
network: "thread",
strength: "strong",
...overrides,
});
const topology = (
nodes: MatterNetworkTopologyNode[],
connections: MatterNetworkTopologyConnection[] = []
): MatterNetworkTopology => ({
collected_at: 1767888000000,
nodes,
connections,
});
const element = document.createElement("div");
document.body.appendChild(element);
describe("strengthToScale", () => {
it("never returns a falsy value so the graph arrow stays suppressed", () => {
expect(strengthToScale("strong")).toBe(4);
expect(strengthToScale("medium")).toBe(3);
expect(strengthToScale("weak")).toBe(2);
expect(strengthToScale("none")).toBe(1);
expect(strengthToScale(undefined)).toBe(1);
expect(strengthToScale(null)).toBe(1);
});
});
describe("getTopologyNodeCategory", () => {
it("maps kinds and roles to categories", () => {
// category 0 is reserved for the synthesized Home Assistant root node
expect(
getTopologyNodeCategory(node({ id: "br", kind: "border_router" }))
).toBe(1);
expect(getTopologyNodeCategory(node({ id: "1", role: "leader" }))).toBe(2);
expect(getTopologyNodeCategory(node({ id: "2", role: "router" }))).toBe(2);
expect(getTopologyNodeCategory(node({ id: "3", role: "reed" }))).toBe(2);
expect(getTopologyNodeCategory(node({ id: "4", role: "end_device" }))).toBe(
3
);
expect(
getTopologyNodeCategory(node({ id: "5", role: "sleepy_end_device" }))
).toBe(3);
expect(
getTopologyNodeCategory(
node({ id: "6", network_type: "wifi", role: "station" })
)
).toBe(3);
expect(
getTopologyNodeCategory(
node({ id: "ap_112233445566", kind: "wifi_ap", network_type: "wifi" })
)
).toBe(4);
expect(
getTopologyNodeCategory(
node({ id: "7", role: "router", available: false })
)
).toBe(5);
expect(
getTopologyNodeCategory(node({ id: "unknown_1", kind: "thread_unknown" }))
).toBe(6);
});
});
describe("getTopologyNodeName", () => {
it("prefers the HA device name", () => {
const hass = mockHass({
dev1: { name_by_user: "Living room plug", name: "Plug" },
});
expect(
getTopologyNodeName(
node({ id: "1", node_id: 1, ha_device_id: "dev1" }),
hass
)
).toBe("Living room plug");
});
it("falls back to wire metadata for external nodes", () => {
const hass = mockHass();
expect(
getTopologyNodeName(
node({ id: "br_1", kind: "border_router", vendor_name: "Apple" }),
hass
)
).toBe("Apple");
expect(
getTopologyNodeName(
node({
id: "ap_112233445566",
kind: "wifi_ap",
network_type: "wifi",
network_name: "MyWiFi",
}),
hass
)
).toBe("MyWiFi");
expect(
getTopologyNodeName(
node({ id: "unknown_1", kind: "thread_unknown" }),
hass
)
).toBe("unknown_device");
});
});
describe("createMatterNetworkChartData", () => {
it("maps a thread mesh with a border router", () => {
const hass = mockHass(
{ dev1: { name: "Leader plug", area_id: "living" } },
{ living: { name: "Living room" } }
);
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, ha_device_id: "dev1", role: "leader" }),
node({ id: "2", node_id: 2, role: "end_device", available: true }),
node({ id: "br_1", kind: "border_router", vendor_name: "Apple" }),
],
[
connection({
source: "1",
target: "2",
strength: "medium",
source_to_target: { strength: "medium", lqi: 2 },
target_to_source: { strength: "medium", lqi: 2 },
}),
connection({
source: "1",
target: "br_1",
source_to_target: { strength: "strong", lqi: 3 },
target_to_source: { strength: "strong", lqi: 3 },
}),
]
),
hass,
element
);
expect(data.categories).toHaveLength(7);
// Home Assistant root + the 3 topology nodes
expect(data.nodes).toHaveLength(4);
const ha = data.nodes[0];
expect(ha.id).toBe("ha");
expect(ha.category).toBe(0);
expect(ha.fixed).toBe(true);
expect(ha.polarDistance).toBe(0);
const leader = data.nodes.find((n) => n.id === "1")!;
expect(leader.name).toBe("Leader plug");
expect(leader.context).toBe("Living room");
expect(leader.category).toBe(2);
expect(leader.itemStyle?.borderWidth).toBe(2);
const endDevice = data.nodes.find((n) => n.id === "2")!;
expect(endDevice.category).toBe(3);
expect(endDevice.itemStyle?.borderWidth).toBeUndefined();
const borderRouter = data.nodes.find((n) => n.id === "br_1")!;
expect(borderRouter.category).toBe(1);
expect(borderRouter.symbol).toBe("roundRect");
const meshLink = data.links.find(
(l) => l.source === "1" && l.target === "2"
)!;
expect(meshLink.value).toBe(3);
expect(meshLink.reverseValue).toBe(3);
expect(meshLink.lineStyle?.type).toBe("solid");
// HA anchors to the border router (the mesh's infrastructure), not to
// the individual routers hanging off it
const haLink = data.links.find((l) => l.source === "ha")!;
expect(haLink.target).toBe("br_1");
expect(haLink.symbol).toBe("none");
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(1);
});
it("marks asymmetric links dashed and keeps one-way arrows", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "2", node_id: 2, role: "router" }),
node({ id: "3", node_id: 3, role: "router" }),
],
[
connection({
source: "1",
target: "2",
source_to_target: { strength: "strong", lqi: 3 },
target_to_source: { strength: "weak", lqi: 1 },
}),
// only observed from node 3's side: 3 → 2
connection({
source: "2",
target: "3",
strength: "medium",
target_to_source: { strength: "medium", lqi: 2 },
}),
]
),
mockHass(),
element
);
const asymmetric = data.links.find(
(l) => l.source === "1" && l.target === "2"
)!;
expect(asymmetric.lineStyle?.type).toBe("dashed");
expect(asymmetric.value).toBe(4);
expect(asymmetric.reverseValue).toBe(2);
// one-way link is flipped so the arrow points the observed direction
const oneWay = data.links.find(
(l) => l.source === "3" && l.target === "2"
)!;
expect(oneWay.reverseValue).toBeUndefined();
expect(oneWay.symbolSize).toBeGreaterThan(0);
expect(oneWay.lineStyle?.type).toBe("dashed");
});
it("suppresses direction arrows on route-table edges", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "2", node_id: 2, role: "router" }),
],
[
connection({
source: "1",
target: "2",
strength: "none",
via_route_table: true,
path_cost: 1,
}),
]
),
mockHass(),
element
);
const link = data.links[0];
expect(link.value).toBe(1);
expect(link.reverseValue).toBe(1);
expect(link.lineStyle?.type).toBe("dotted");
});
it("keeps every node attached to the force layout", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "2", node_id: 2, role: "router" }),
node({ id: "3", node_id: 3, role: "end_device" }),
node({ id: "br_1", kind: "border_router" }),
],
[
connection({
source: "1",
target: "br_1",
source_to_target: { strength: "strong", lqi: 3 },
target_to_source: { strength: "strong", lqi: 3 },
}),
connection({
source: "1",
target: "2",
strength: "weak",
source_to_target: { strength: "weak", lqi: 1 },
target_to_source: { strength: "weak", lqi: 1 },
}),
connection({
source: "2",
target: "3",
strength: "weak",
source_to_target: { strength: "weak", lqi: 1 },
target_to_source: { strength: "weak", lqi: 1 },
}),
]
),
mockHass(),
element
);
// hub link stays active, and every node has at least one active link
const hubLink = data.links.find((l) => l.target === "br_1")!;
expect(hubLink.ignoreForceLayout).toBe(false);
data.nodes.forEach((n) => {
const nodeLinks = data.links.filter(
(l) => l.source === n.id || l.target === n.id
);
expect(
nodeLinks.some((l) => !l.ignoreForceLayout),
`node ${n.id} has no active link`
).toBe(true);
});
});
it("tolerates minimal nodes and skips connections to unknown nodes", () => {
const data = createMatterNetworkChartData(
topology(
[node({ id: "1" })],
[connection({ source: "1", target: "missing" })]
),
mockHass(),
element
);
// Home Assistant root + the single topology node
expect(data.nodes).toHaveLength(2);
// the bogus connection is skipped; the lone node is anchored to HA
expect(data.links).toHaveLength(1);
expect(data.links[0].source).toBe("ha");
expect(data.links[0].target).toBe("1");
});
it("anchors unconnected routers directly to Home Assistant", () => {
const data = createMatterNetworkChartData(
topology([
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "2", node_id: 2, role: "router" }),
]),
mockHass(),
element
);
const haTargets = data.links
.filter((l) => l.source === "ha")
.map((l) => l.target)
.sort();
expect(haTargets).toEqual(["1", "2"]);
});
it("routes HA through the Wi-Fi access point, not the stations", () => {
const data = createMatterNetworkChartData(
topology(
[
node({
id: "ap_112233445566",
kind: "wifi_ap",
network_type: "wifi",
}),
node({ id: "7", node_id: 7, network_type: "wifi", role: "station" }),
node({ id: "8", node_id: 8, network_type: "wifi", role: "station" }),
],
[
connection({
source: "7",
target: "ap_112233445566",
network: "wifi",
source_to_target: { strength: "strong", rssi: -55 },
}),
connection({
source: "8",
target: "ap_112233445566",
network: "wifi",
source_to_target: { strength: "medium", rssi: -70 },
}),
]
),
mockHass(),
element
);
const haTargets = data.links
.filter((l) => l.source === "ha")
.map((l) => l.target);
expect(haTargets).toEqual(["ap_112233445566"]);
});
it("adds the network name to the context when there are multiple networks", () => {
const data = createMatterNetworkChartData(
topology([
node({
id: "1",
node_id: 1,
ext_pan_id: "AAA",
network_name: "NetA",
}),
node({
id: "2",
node_id: 2,
ext_pan_id: "BBB",
network_name: "NetB",
}),
]),
mockHass(),
element
);
expect(data.nodes.find((n) => n.id === "1")!.context).toBe("NetA");
expect(data.nodes.find((n) => n.id === "2")!.context).toBe("NetB");
});
});