diff --git a/src/components/chart/ha-network-graph.ts b/src/components/chart/ha-network-graph.ts index c9693419dc..aa4052afcf 100644 --- a/src/components/chart/ha-network-graph.ts +++ b/src/components/chart/ha-network-graph.ts @@ -10,12 +10,12 @@ 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"; export interface NetworkNode { id: string; name?: string; category?: number; - label?: string; value?: number; symbolSize?: number; symbol?: string; @@ -60,7 +60,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 +77,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 +91,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 +112,7 @@ export class HaNetworkGraph extends LitElement { this._physicsEnabled, this._reducedMotion, this._showLabels - )} + ) as GraphSeriesOption} .options=${this._createOptions(this.data?.categories)} height="100%" .extraComponents=${[GraphChart]} @@ -180,75 +170,81 @@ 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 = + 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 || [], + }; } ); 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 +261,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 b0e5ae8cbe..53ec41cf67 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; @@ -1067,3 +1069,12 @@ export const cancelSecureBootstrapS2 = ( type: "zwave_js/cancel_secure_bootstrap_s2", entry_id, }); + +export const fetchZwaveNeighbors = ( + hass: HomeAssistant, + entry_id: string +): Promise> => + hass.callWS({ + type: "zwave_js/get_neighbors", + entry_id, + }); \ No newline at end of file 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 index d6b8d5a68c..8beee0ef38 100644 --- 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 @@ -1,5 +1,5 @@ import { customElement, property, state } from "lit/decorators"; -import { css, html, LitElement, nothing } from "lit"; +import { css, html, LitElement } from "lit"; import memoizeOne from "memoize-one"; import type { HomeAssistant, Route } from "../../../../../types"; import { configTabs } from "./zwave_js-config-router"; @@ -11,8 +11,18 @@ import type { } from "../../../../../components/chart/ha-network-graph"; import "../../../../../components/chart/ha-network-graph"; import "../../../../../layouts/hass-tabs-subpage"; -import { fetchZwaveNetworkStatus } from "../../../../../data/zwave_js"; +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"; @customElement("zwave_js-network-visualization") export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) { @@ -26,16 +36,36 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) { @property({ attribute: false }) public configEntryId!: string; - @state() private _data: NetworkData | null = null; + @state() private _nodeStatuses: Record = {}; - protected async firstUpdated() { - this._data = await this._getNetworkData(); + @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() { - if (!this._data) { - return nothing; - } return html` `; } - private _getNetworkData = memoizeOne(async (): Promise => { - 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"], - }, - }, - ]; + private async _fetchNetworkStatus() { const network = await fetchZwaveNetworkStatus(this.hass!, { entry_id: this.configEntryId, }); + const nodeStatuses: Record = {}; network.controller.nodes.forEach((node) => { - nodes.push({ - id: String(node.node_id), - label: String(node.node_id), - fixed: node.is_controller_node, - polarDistance: node.is_controller_node ? 0 : 0.5, - category: node.is_controller_node ? 0 : 1, - }); + nodeStatuses[node.node_id] = node; }); - return { nodes, links, categories }; - }); + this._nodeStatuses = nodeStatuses; + + // const neighbors = await fetchZwaveNeighbors(this.hass!, this.configEntryId); + // console.log("neighbors", neighbors); + } + + 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), + fixed: node.is_controller_node, + polarDistance: node.is_controller_node + ? 0 + : node.status === NodeStatus.Dead + ? 1 + : 0.5, + category: + node.status === NodeStatus.Dead + ? 3 + : node.status === NodeStatus.Asleep + ? 2 + : node.is_controller_node + ? 0 + : 1, + 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"], + }, + }); + }); + + Object.entries(nodeStatistics).forEach(([nodeId, stats]) => { + const route = stats.lwr || stats.nlwr; + if (route) { + const hops = [ + ...route.repeaters + .map( + (id) => + Object.entries(this._devices).find( + ([_nodeId, d]) => d.id === id + )?.[0] + ) + .filter(Boolean), + controllerNode!, + ]; + let sourceNode = nodeId; + hops.forEach((repeater) => { + links.push({ + source: String(sourceNode), + target: String(repeater), + }); + 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) { // @TODO diff --git a/src/translations/en.json b/src/translations/en.json index 915d0fac6a..e3347b9311 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6249,7 +6249,9 @@ }, "visualization": { "controller": "Controller", - "node": "Node" + "node": "Node", + "asleep_node": "Asleep Node", + "dead_node": "Dead Node" }, "node_installer": { "header": "Installer Settings",