mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-19 04:57:06 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
651cb7934a | ||
|
|
1e1ff92dd2 | ||
|
|
3a58565e5e | ||
|
|
c299ea9663 | ||
|
|
a2d47af076 | ||
|
|
954d0baa65 | ||
|
|
9a945f4105 | ||
|
|
20be632c96 | ||
|
|
89bc7a40f2 | ||
|
|
924cc057b9 | ||
|
|
43c9e71a8f | ||
|
|
a98a598c79 | ||
|
|
e6ec2540e7 |
@@ -45,6 +45,73 @@ 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;
|
||||
|
||||
@@ -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,360 @@
|
||||
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 "--orange-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);
|
||||
|
||||
// a hub wears its transport's colour, the same hue as the links behind it
|
||||
const categoryColors = [
|
||||
style.getPropertyValue("--primary-color"),
|
||||
style.getPropertyValue(networkToColorVar("thread")),
|
||||
style.getPropertyValue("--cyan-color"),
|
||||
style.getPropertyValue("--teal-color"),
|
||||
style.getPropertyValue(networkToColorVar("wifi")),
|
||||
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;
|
||||
}
|
||||
// the summary strength is the strongest observed direction, so "none" means
|
||||
// every direction is dead -- a stale neighbour entry the dashboard also
|
||||
// refuses to draw
|
||||
if (conn.strength === "none") {
|
||||
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;
|
||||
// an edge is lower confidence when an endpoint is inferred rather than
|
||||
// commissioned, or is offline -- the dashboard dashes on the same two
|
||||
const lowConfidence = [source, target].some((id) => {
|
||||
const category = nodeCategories.get(id);
|
||||
return category === CATEGORY_UNKNOWN || category === CATEGORY_OFFLINE;
|
||||
});
|
||||
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(networkToColorVar(conn.network)),
|
||||
type:
|
||||
oneWay || asymmetric || lowConfidence
|
||||
? "dashed"
|
||||
: !forward && conn.via_route_table
|
||||
? "dotted"
|
||||
: "solid",
|
||||
},
|
||||
ignoreForceLayout: !(
|
||||
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
// Only a hub gets an edge to HA, and it is a real path. A node whose route we
|
||||
// cannot see gets nothing: inventing an edge to HA reads as a physical link.
|
||||
// It keeps the HA node's own color rather than a transport hue.
|
||||
// `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, network: string): NetworkLink => ({
|
||||
source: HOME_ASSISTANT_NODE_ID,
|
||||
target,
|
||||
value: 0,
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
// the same hue as the radio links behind this hub, so one transport
|
||||
// reads as one colour all the way back to Home Assistant
|
||||
color: style.getPropertyValue(networkToColorVar(network)),
|
||||
type: "solid",
|
||||
},
|
||||
});
|
||||
|
||||
// HA reaches the mesh through the border routers and Wi-Fi access points
|
||||
topology.nodes
|
||||
.filter((node) => node.kind === "border_router" || node.kind === "wifi_ap")
|
||||
.forEach((node) =>
|
||||
links.push(haLink(node.id, node.kind === "wifi_ap" ? "wifi" : "thread"))
|
||||
);
|
||||
|
||||
// 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 };
|
||||
}
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8422,6 +8422,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}",
|
||||
@@ -8470,6 +8471,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
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("--orange-color", "#ff9800");
|
||||
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("--orange-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",
|
||||
// no measurement is "unknown"; "none" means observed dead
|
||||
strength: "unknown",
|
||||
via_route_table: true,
|
||||
path_cost: 1,
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const link = data.links[0];
|
||||
expect(link.value).toBe(2);
|
||||
expect(link.reverseValue).toBe(2);
|
||||
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, and a node with no visible route to
|
||||
// Home Assistant gets no invented edge either
|
||||
expect(data.links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("floats routers whose route to Home Assistant is not visible", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "2", node_id: 2, role: "router" }),
|
||||
]),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
// with no border router or access point in the graph there is no known
|
||||
// path, and a drawn edge would read as a physical link
|
||||
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(0);
|
||||
expect(data.nodes.map((n) => n.id).sort()).toEqual(["1", "2", "ha"]);
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
// neither the unknown pair nor the hubless router gets an edge to HA
|
||||
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(0);
|
||||
// the unknown pair keeps its own mesh edge
|
||||
expect(data.links.filter((l) => l.source === "unknown_1")).toHaveLength(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 must not mean dropping the node out of the graph
|
||||
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(0);
|
||||
expect(data.nodes.find((n) => n.id === "unknown_1")).toBeDefined();
|
||||
expect(data.links.filter((l) => l.source === "1")).toHaveLength(1);
|
||||
});
|
||||
|
||||
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("#ff9800");
|
||||
expect(wifiLink.lineStyle?.width).toBe(3);
|
||||
|
||||
// each hub node wears the same hue as the links behind it
|
||||
expect(data.nodes.find((n) => n.id === "ap_1")!.itemStyle?.color).toBe(
|
||||
"#ff9800"
|
||||
);
|
||||
expect(data.nodes.find((n) => n.id === "br_1")!.itemStyle?.color).toBe(
|
||||
"#926bc7"
|
||||
);
|
||||
|
||||
// an HA edge carries the hue of the transport behind that hub, so one
|
||||
// network reads as one color the whole way back
|
||||
const threadSpine = data.links.find(
|
||||
(l) => l.source === "ha" && l.target === "br_1"
|
||||
)!;
|
||||
const wifiSpine = data.links.find(
|
||||
(l) => l.source === "ha" && l.target === "ap_1"
|
||||
)!;
|
||||
expect(threadSpine.lineStyle?.color).toBe("#926bc7");
|
||||
expect(wifiSpine.lineStyle?.color).toBe("#ff9800");
|
||||
});
|
||||
|
||||
it("does not draw a link whose every direction is dead", () => {
|
||||
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()
|
||||
);
|
||||
|
||||
// the summary strength is the strongest direction, so "none" means every
|
||||
// direction is dead -- a stale entry, not a weak link
|
||||
expect(data.links.find((l) => l.source === "1")).toBeUndefined();
|
||||
// the border router keeps its own edge to Home Assistant
|
||||
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("dashes an edge to an inferred or offline endpoint", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "unknown_1", kind: "thread_unknown" }),
|
||||
node({ id: "6", node_id: 6, role: "end_device", available: false }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "1",
|
||||
target: "unknown_1",
|
||||
source_to_target: { strength: "medium", lqi: 2 },
|
||||
target_to_source: { strength: "medium", lqi: 2 },
|
||||
}),
|
||||
connection({
|
||||
source: "1",
|
||||
target: "6",
|
||||
source_to_target: { strength: "medium", lqi: 2 },
|
||||
target_to_source: { strength: "medium", lqi: 2 },
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
// symmetric and two-way, so only the endpoint lowers the confidence
|
||||
const inferred = data.links.find((l) => l.target === "unknown_1")!;
|
||||
const offline = data.links.find((l) => l.target === "6")!;
|
||||
expect(inferred.lineStyle?.type).toBe("dashed");
|
||||
expect(offline.lineStyle?.type).toBe("dashed");
|
||||
});
|
||||
|
||||
it("links Home Assistant to hubs only, and never to a hubless node", () => {
|
||||
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");
|
||||
// symbol, not the falsy value, is what keeps the arrowhead off
|
||||
expect(hubLink.symbol).toBe("none");
|
||||
expect(hubLink.reverseValue).toBeUndefined();
|
||||
|
||||
// node 9 has no route we can see, so it is left floating
|
||||
expect(
|
||||
data.links.find((l) => l.source === "ha" && l.target === "9")
|
||||
).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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user