From faae7a23225fbc634f8b8787bb626d5dbfb63907 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 18 Jul 2025 11:12:28 +0300 Subject: [PATCH] ZWaveJS network graph (#26112) * ZwaveJS network visualization * more progress * working version * lint * remove unused code * Update src/translations/en.json Co-authored-by: Norbert Rittel * remove "live" toggle and use deepEqual * styling tweak --------- Co-authored-by: Norbert Rittel --- src/components/chart/ha-network-graph.ts | 168 +++++---- src/data/zwave_js.ts | 2 + .../zha/zha-network-visualization-page.ts | 1 + .../zwave_js/zwave_js-config-router.ts | 11 +- .../zwave_js-network-visualization.ts | 326 ++++++++++++++++++ src/translations/en.json | 30 +- 6 files changed, 448 insertions(+), 90 deletions(-) create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts diff --git a/src/components/chart/ha-network-graph.ts b/src/components/chart/ha-network-graph.ts index c9693419dc..9edd2eccaa 100644 --- a/src/components/chart/ha-network-graph.ts +++ b/src/components/chart/ha-network-graph.ts @@ -10,12 +10,13 @@ import type { ECOption } from "../../resources/echarts"; import "./ha-chart-base"; import type { HaChartBase } from "./ha-chart-base"; import type { HomeAssistant } from "../../types"; +import { SubscribeMixin } from "../../mixins/subscribe-mixin"; +import { deepEqual } from "../../common/util/deep-equal"; export interface NetworkNode { id: string; name?: string; category?: number; - label?: string; value?: number; symbolSize?: number; symbol?: string; @@ -60,7 +61,7 @@ export interface NetworkData { let GraphChart: typeof import("echarts/lib/chart/graph/install"); @customElement("ha-network-graph") -export class HaNetworkGraph extends LitElement { +export class HaNetworkGraph extends SubscribeMixin(LitElement) { public chart?: EChartsType; @property({ attribute: false }) public data!: NetworkData; @@ -77,8 +78,6 @@ export class HaNetworkGraph extends LitElement { @state() private _showLabels = true; - private _listeners: (() => void)[] = []; - private _nodePositions: Record = {}; @query("ha-chart-base") private _baseChart?: HaChartBase; @@ -93,22 +92,14 @@ export class HaNetworkGraph extends LitElement { } } - public async connectedCallback() { - super.connectedCallback(); - this._listeners.push( + protected hassSubscribe() { + return [ listenMediaQuery("(prefers-reduced-motion)", (matches) => { if (this._reducedMotion !== matches) { this._reducedMotion = matches; } - }) - ); - } - - public disconnectedCallback() { - super.disconnectedCallback(); - while (this._listeners.length) { - this._listeners.pop()!(); - } + }), + ]; } protected render() { @@ -122,7 +113,7 @@ export class HaNetworkGraph extends LitElement { this._physicsEnabled, this._reducedMotion, this._showLabels - )} + ) as GraphSeriesOption} .options=${this._createOptions(this.data?.categories)} height="100%" .extraComponents=${[GraphChart]} @@ -168,7 +159,8 @@ export class HaNetworkGraph extends LitElement { type: "inside", filterMode: "none", }, - }) + }), + deepEqual ); private _getSeries = memoizeOne( @@ -180,75 +172,80 @@ export class HaNetworkGraph extends LitElement { ) => { const containerWidth = this.clientWidth; const containerHeight = this.clientHeight; - return [ - { - id: "network", - type: "graph", - layout: physicsEnabled ? "force" : "none", - draggable: true, - roam: true, - selectedMode: "single", - label: { - show: showLabels, - position: "right", - }, - emphasis: { - focus: "adjacency", - }, - force: { - repulsion: [400, 600], - edgeLength: [200, 300], - gravity: 0.1, - layoutAnimation: !reducedMotion && data.nodes.length < 100, - }, - edgeSymbol: ["none", "arrow"], - edgeSymbolSize: 10, - data: data.nodes.map((node) => { - const echartsNode: NonNullable[number] = - { - id: node.id, - name: node.name, - category: node.category, - value: node.value, - symbolSize: node.symbolSize || 30, - symbol: node.symbol || "circle", - itemStyle: node.itemStyle || {}, - fixed: node.fixed, - }; - if (this._nodePositions[node.id]) { - echartsNode.x = this._nodePositions[node.id].x; - echartsNode.y = this._nodePositions[node.id].y; - } else if (typeof node.polarDistance === "number") { - // set the position of the node at polarDistance from the center in a random direction - const angle = Math.random() * 2 * Math.PI; - echartsNode.x = - containerWidth / 2 + - ((Math.cos(angle) * containerWidth) / 2) * node.polarDistance; - echartsNode.y = - containerHeight / 2 + - ((Math.sin(angle) * containerHeight) / 2) * node.polarDistance; - this._nodePositions[node.id] = { - x: echartsNode.x, - y: echartsNode.y, - }; - } - return echartsNode; - }), - links: data.links.map((link) => ({ - ...link, - value: link.reverseValue - ? Math.max(link.value ?? 0, link.reverseValue) - : link.value, - // remove arrow for bidirectional links - symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work - })), - categories: data.categories || [], + return { + id: "network", + type: "graph", + layout: physicsEnabled ? "force" : "none", + draggable: true, + roam: true, + selectedMode: "single", + label: { + show: showLabels, + position: "right", }, - ] as any; - } + emphasis: { + focus: "adjacency", + }, + force: { + repulsion: [400, 600], + edgeLength: [200, 300], + gravity: 0.1, + layoutAnimation: !reducedMotion && data.nodes.length < 100, + }, + edgeSymbol: ["none", "arrow"], + edgeSymbolSize: 10, + data: data.nodes.map((node) => { + const echartsNode: NonNullable[number] = { + id: node.id, + name: node.name, + category: node.category, + value: node.value, + symbolSize: node.symbolSize || 30, + symbol: node.symbol || "circle", + itemStyle: node.itemStyle || {}, + fixed: node.fixed, + }; + if (this._nodePositions[node.id]) { + echartsNode.x = this._nodePositions[node.id].x; + echartsNode.y = this._nodePositions[node.id].y; + } else if (typeof node.polarDistance === "number") { + // set the position of the node at polarDistance from the center in a random direction + const angle = Math.random() * 2 * Math.PI; + echartsNode.x = + ((Math.cos(angle) * containerWidth) / 2) * node.polarDistance; + echartsNode.y = + ((Math.sin(angle) * containerHeight) / 2) * node.polarDistance; + this._nodePositions[node.id] = { + x: echartsNode.x, + y: echartsNode.y, + }; + } + return echartsNode; + }), + links: data.links.map((link) => ({ + ...link, + value: link.reverseValue + ? Math.max(link.value ?? 0, link.reverseValue) + : link.value, + // remove arrow for bidirectional links + symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work + })), + categories: data.categories || [], + }; + }, + deepEqual ); private _togglePhysics() { + this._saveNodePositions(); + this._physicsEnabled = !this._physicsEnabled; + } + + private _toggleLabels() { + this._showLabels = !this._showLabels; + } + + private _saveNodePositions() { if (this._baseChart?.chart) { this._baseChart.chart // @ts-ignore private method but no other way to get the graph positions @@ -265,11 +262,6 @@ export class HaNetworkGraph extends LitElement { } }); } - this._physicsEnabled = !this._physicsEnabled; - } - - private _toggleLabels() { - this._showLabels = !this._showLabels; } static styles = css` diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index 8f756d9d1c..78caa83cd0 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -358,6 +358,8 @@ export enum ProtocolDataRate { export interface ZWaveJSNodeStatisticsUpdatedMessage { event: "statistics updated"; source: "node"; + nodeId?: number; + node_id?: number; commands_tx: number; commands_rx: number; commands_dropped_tx: number; diff --git a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts index d6eefa08b9..dffe1d8c7f 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts @@ -226,6 +226,7 @@ export class ZHANetworkVisualizationPage extends LitElement { : offlineColor, }, polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9, + fixed: isCoordinator, }); // Create links (edges) diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts index 83cac24c77..25cffcf1db 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts @@ -1,4 +1,4 @@ -import { mdiServerNetwork, mdiMathLog } from "@mdi/js"; +import { mdiServerNetwork, mdiMathLog, mdiNetwork } from "@mdi/js"; import { customElement, property } from "lit/decorators"; import type { RouterOptions } from "../../../../../layouts/hass-router-page"; import { HassRouterPage } from "../../../../../layouts/hass-router-page"; @@ -18,6 +18,11 @@ export const configTabs: PageNavigation[] = [ path: `/config/zwave_js/logs`, iconPath: mdiMathLog, }, + { + translationKey: "ui.panel.config.zwave_js.navigation.visualization", + path: `/config/zwave_js/visualization`, + iconPath: mdiNetwork, + }, ]; @customElement("zwave_js-config-router") @@ -60,6 +65,10 @@ class ZWaveJSConfigRouter extends HassRouterPage { tag: "zwave_js-provisioned", load: () => import("./zwave_js-provisioned"), }, + visualization: { + tag: "zwave_js-network-visualization", + load: () => import("./zwave_js-network-visualization"), + }, }, initialLoad: () => this._fetchConfigEntries(), }; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts new file mode 100644 index 0000000000..444b2eb0a8 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts @@ -0,0 +1,326 @@ +import { customElement, property, state } from "lit/decorators"; +import { css, html, LitElement } from "lit"; +import memoizeOne from "memoize-one"; +import type { + CallbackDataParams, + TopLevelFormatterParams, +} from "echarts/types/dist/shared"; +import type { HomeAssistant, Route } from "../../../../../types"; +import { configTabs } from "./zwave_js-config-router"; +import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; +import type { + NetworkData, + NetworkLink, + NetworkNode, +} from "../../../../../components/chart/ha-network-graph"; +import "../../../../../components/chart/ha-network-graph"; +import "../../../../../layouts/hass-tabs-subpage"; +import { + fetchZwaveNetworkStatus, + NodeStatus, + subscribeZwaveNodeStatistics, +} from "../../../../../data/zwave_js"; +import type { + ZWaveJSNodeStatisticsUpdatedMessage, + ZWaveJSNodeStatus, +} from "../../../../../data/zwave_js"; +import { colorVariables } from "../../../../../resources/theme/color.globals"; +import type { DeviceRegistryEntry } from "../../../../../data/device_registry"; +import { debounce } from "../../../../../common/util/debounce"; +import { navigate } from "../../../../../common/navigate"; + +@customElement("zwave_js-network-visualization") +export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) { + public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ attribute: "is-wide", type: Boolean }) public isWide = false; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: false }) public configEntryId!: string; + + @state() private _nodeStatuses: Record = {}; + + @state() private _nodeStatistics: Record< + number, + ZWaveJSNodeStatisticsUpdatedMessage + > = {}; + + @state() private _devices: Record = {}; + + public hassSubscribe() { + const devices = Object.values(this.hass.devices).filter((device) => + device.config_entries.some((entry) => entry === this.configEntryId) + ); + + return devices.map((device) => + subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => { + const nodeId = message.nodeId ?? message.node_id; + this._devices[nodeId!] = device; + this._nodeStatistics[nodeId!] = message; + this._handleUpdatedNodeStatistics(); + }) + ); + } + + public connectedCallback() { + super.connectedCallback(); + this._fetchNetworkStatus(); + } + + protected render() { + return html` + + + `; + } + + private async _fetchNetworkStatus() { + const network = await fetchZwaveNetworkStatus(this.hass!, { + entry_id: this.configEntryId, + }); + const nodeStatuses: Record = {}; + network.controller.nodes.forEach((node) => { + nodeStatuses[node.node_id] = node; + }); + + this._nodeStatuses = nodeStatuses; + } + + private _tooltipFormatter = (params: TopLevelFormatterParams): string => { + const { dataType, data } = params as CallbackDataParams; + if (dataType === "edge") { + const { source, target, value } = data as any; + const sourceDevice = this._devices[source]; + const targetDevice = this._devices[target]; + const sourceName = + sourceDevice?.name_by_user ?? sourceDevice?.name ?? source; + const targetName = + targetDevice?.name_by_user ?? targetDevice?.name ?? target; + let tip = `${sourceName} → ${targetName}`; + const route = + this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr; + if (route?.protocol_data_rate) { + tip += `
${this.hass.localize("ui.panel.config.zwave_js.visualization.data_rate")}: ${this.hass.localize(`ui.panel.config.zwave_js.protocol_data_rate.${route.protocol_data_rate}`)}`; + } + if (value) { + tip += `
RSSI: ${value}`; + } + return tip; + } + const { id, name } = data as any; + const device = this._devices[id]; + const nodeStatus = this._nodeStatuses[id]; + let tip = `${(params as any).marker} ${name}`; + tip += `
${this.hass.localize("ui.panel.config.zwave_js.visualization.node_id")}: ${id}`; + if (device) { + tip += `
${this.hass.localize("ui.panel.config.zwave_js.visualization.manufacturer")}: ${device.manufacturer || "-"}`; + tip += `
${this.hass.localize("ui.panel.config.zwave_js.visualization.model")}: ${device.model || "-"}`; + } + if (nodeStatus) { + tip += `
${this.hass.localize("ui.panel.config.zwave_js.visualization.status")}: ${this.hass.localize(`ui.panel.config.zwave_js.node_status.${nodeStatus.status}`)}`; + if (nodeStatus.zwave_plus_version) { + tip += `
Z-Wave Plus: ${this.hass.localize("ui.panel.config.zwave_js.visualization.version")} ${nodeStatus.zwave_plus_version}`; + } + } + return tip; + }; + + private _getNetworkData = memoizeOne( + ( + nodeStatuses: Record, + nodeStatistics: Record + ): NetworkData => { + const nodes: NetworkNode[] = []; + const links: NetworkLink[] = []; + const categories = [ + { + name: this.hass.localize( + "ui.panel.config.zwave_js.visualization.controller" + ), + symbol: "roundRect", + itemStyle: { + color: colorVariables["primary-color"], + }, + }, + { + name: this.hass.localize( + "ui.panel.config.zwave_js.visualization.node" + ), + symbol: "circle", + itemStyle: { + color: colorVariables["cyan-color"], + }, + }, + { + name: this.hass.localize( + "ui.panel.config.zwave_js.visualization.asleep_node" + ), + symbol: "circle", + itemStyle: { + color: colorVariables["disabled-color"], + }, + }, + { + name: this.hass.localize( + "ui.panel.config.zwave_js.visualization.dead_node" + ), + symbol: "circle", + itemStyle: { + color: colorVariables["error-color"], + }, + }, + ]; + if (!Object.keys(nodeStatuses).length) { + return { nodes, links, categories }; + } + + let controllerNode: number | undefined; + Object.values(nodeStatuses).forEach((node) => { + if (node.is_controller_node) { + controllerNode = node.node_id; + } + const device = this._devices[node.node_id]; + nodes.push({ + id: String(node.node_id), + name: device?.name_by_user ?? device?.name ?? String(node.node_id), + value: node.is_controller_node ? 3 : node.is_routing ? 2 : 1, + category: + node.status === NodeStatus.Dead + ? 3 + : node.status === NodeStatus.Asleep + ? 2 + : node.is_controller_node + ? 0 + : 1, + symbolSize: node.is_controller_node ? 40 : node.is_routing ? 30 : 20, + symbol: node.is_controller_node ? "roundRect" : "circle", + itemStyle: { + color: + node.status === NodeStatus.Dead + ? colorVariables["error-color"] + : node.status === NodeStatus.Asleep + ? colorVariables["disabled-color"] + : node.is_controller_node + ? colorVariables["primary-color"] + : colorVariables["cyan-color"], + }, + polarDistance: node.is_controller_node + ? 0 + : node.status === NodeStatus.Dead + ? 0.9 + : 0.5, + fixed: node.is_controller_node, + }); + }); + + Object.entries(nodeStatistics).forEach(([nodeId, stats]) => { + const route = stats.lwr || stats.nlwr; + if (route) { + const hops = [ + ...route.repeaters.map((id, i) => [ + Object.keys(this._devices).find( + (_nodeId) => this._devices[_nodeId]?.id === id + )?.[0], + route.repeater_rssi[i], + ]), + [controllerNode!, route.rssi], + ]; + let sourceNode: string = nodeId; + hops.forEach(([repeater, rssi]) => { + const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100; + const existingLink = links.find( + (link) => + link.source === sourceNode && link.target === String(repeater) + ); + const width = this._getLineWidth(RSSI); + if (existingLink) { + existingLink.value = Math.max(existingLink.value!, RSSI); + existingLink.lineStyle = { + ...existingLink.lineStyle, + width: Math.max(existingLink.lineStyle!.width!, width), + type: + route.protocol_data_rate > 1 + ? "solid" + : existingLink.lineStyle!.type, + }; + } else { + links.push({ + source: sourceNode, + target: String(repeater), + value: RSSI, + lineStyle: { + width, + color: + repeater === controllerNode + ? colorVariables["primary-color"] + : colorVariables["disabled-color"], + type: route.protocol_data_rate > 1 ? "solid" : "dotted", + }, + symbolSize: width * 3, + }); + } + sourceNode = String(repeater); + }); + } + }); + + return { nodes, links, categories }; + } + ); + + private _handleUpdatedNodeStatistics = debounce(() => { + // all the node events come in at once, so we need to debounce to avoid + // unnecessary re-renders + this._nodeStatistics = { ...this._nodeStatistics }; + }, 500); + + private _handleChartClick(e: CustomEvent) { + if ( + e.detail.dataType === "node" && + e.detail.event.target.cursor === "pointer" + ) { + const { id } = e.detail.data; + const device = this._devices[id]; + if (device) { + navigate(`/config/devices/device/${device.id}`); + } + } + } + + private _getLineWidth(rssi: number): number { + return rssi > -50 ? 3 : rssi > -75 ? 2 : 1; + } + + static get styles() { + return [ + css` + ha-network-graph { + height: 100%; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "zwave_js-network-visualization": ZWaveJSNetworkVisualization; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 0787040603..93be6ec206 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5875,7 +5875,8 @@ "zwave_js": { "navigation": { "network": "Network", - "logs": "Logs" + "logs": "Logs", + "visualization": "Visualization" }, "common": { "network": "Network", @@ -6325,6 +6326,33 @@ "log_level_changed": "Log level changed to: {level}", "download_logs": "Download logs" }, + "visualization": { + "controller": "Controller", + "node": "Node", + "asleep_node": "Asleep node", + "dead_node": "Dead node", + "toggle_live": "Toggle live updates", + "node_id": "Node ID", + "manufacturer": "Manufacturer", + "model": "Model", + "status": "Status", + "version": "Version", + "data_rate": "Data rate" + }, + "node_status": { + "0": "Unknown", + "1": "Asleep", + "2": "Awake", + "3": "Dead", + "4": "Alive" + }, + "protocol_data_rate": { + "0": "Unspecified", + "1": "Z-Wave 9.6 kbps", + "2": "Z-Wave 40 kbps", + "3": "Z-Wave 100 kbps", + "4": "Long Range 100 kbps" + }, "node_installer": { "header": "Installer settings", "introduction": "Configure your device installer settings.",