more progress

This commit is contained in:
Petar Petrov 2025-06-23 08:48:34 +03:00
parent 100525735c
commit 5b74970733
4 changed files with 262 additions and 126 deletions

View File

@ -10,12 +10,12 @@ import type { ECOption } from "../../resources/echarts";
import "./ha-chart-base"; import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base"; import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
export interface NetworkNode { export interface NetworkNode {
id: string; id: string;
name?: string; name?: string;
category?: number; category?: number;
label?: string;
value?: number; value?: number;
symbolSize?: number; symbolSize?: number;
symbol?: string; symbol?: string;
@ -60,7 +60,7 @@ export interface NetworkData {
let GraphChart: typeof import("echarts/lib/chart/graph/install"); let GraphChart: typeof import("echarts/lib/chart/graph/install");
@customElement("ha-network-graph") @customElement("ha-network-graph")
export class HaNetworkGraph extends LitElement { export class HaNetworkGraph extends SubscribeMixin(LitElement) {
public chart?: EChartsType; public chart?: EChartsType;
@property({ attribute: false }) public data!: NetworkData; @property({ attribute: false }) public data!: NetworkData;
@ -77,8 +77,6 @@ export class HaNetworkGraph extends LitElement {
@state() private _showLabels = true; @state() private _showLabels = true;
private _listeners: (() => void)[] = [];
private _nodePositions: Record<string, { x: number; y: number }> = {}; private _nodePositions: Record<string, { x: number; y: number }> = {};
@query("ha-chart-base") private _baseChart?: HaChartBase; @query("ha-chart-base") private _baseChart?: HaChartBase;
@ -93,22 +91,14 @@ export class HaNetworkGraph extends LitElement {
} }
} }
public async connectedCallback() { protected hassSubscribe() {
super.connectedCallback(); return [
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => { listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) { if (this._reducedMotion !== matches) {
this._reducedMotion = matches; this._reducedMotion = matches;
} }
}) }),
); ];
}
public disconnectedCallback() {
super.disconnectedCallback();
while (this._listeners.length) {
this._listeners.pop()!();
}
} }
protected render() { protected render() {
@ -122,7 +112,7 @@ export class HaNetworkGraph extends LitElement {
this._physicsEnabled, this._physicsEnabled,
this._reducedMotion, this._reducedMotion,
this._showLabels this._showLabels
)} ) as GraphSeriesOption}
.options=${this._createOptions(this.data?.categories)} .options=${this._createOptions(this.data?.categories)}
height="100%" height="100%"
.extraComponents=${[GraphChart]} .extraComponents=${[GraphChart]}
@ -180,8 +170,7 @@ export class HaNetworkGraph extends LitElement {
) => { ) => {
const containerWidth = this.clientWidth; const containerWidth = this.clientWidth;
const containerHeight = this.clientHeight; const containerHeight = this.clientHeight;
return [ return {
{
id: "network", id: "network",
type: "graph", type: "graph",
layout: physicsEnabled ? "force" : "none", layout: physicsEnabled ? "force" : "none",
@ -204,8 +193,7 @@ export class HaNetworkGraph extends LitElement {
edgeSymbol: ["none", "arrow"], edgeSymbol: ["none", "arrow"],
edgeSymbolSize: 10, edgeSymbolSize: 10,
data: data.nodes.map((node) => { data: data.nodes.map((node) => {
const echartsNode: NonNullable<GraphSeriesOption["data"]>[number] = const echartsNode: NonNullable<GraphSeriesOption["data"]>[number] = {
{
id: node.id, id: node.id,
name: node.name, name: node.name,
category: node.category, category: node.category,
@ -243,12 +231,20 @@ export class HaNetworkGraph extends LitElement {
symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work
})), })),
categories: data.categories || [], categories: data.categories || [],
}, };
] as any;
} }
); );
private _togglePhysics() { private _togglePhysics() {
this._saveNodePositions();
this._physicsEnabled = !this._physicsEnabled;
}
private _toggleLabels() {
this._showLabels = !this._showLabels;
}
private _saveNodePositions() {
if (this._baseChart?.chart) { if (this._baseChart?.chart) {
this._baseChart.chart this._baseChart.chart
// @ts-ignore private method but no other way to get the graph positions // @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` static styles = css`

View File

@ -358,6 +358,8 @@ export enum ProtocolDataRate {
export interface ZWaveJSNodeStatisticsUpdatedMessage { export interface ZWaveJSNodeStatisticsUpdatedMessage {
event: "statistics updated"; event: "statistics updated";
source: "node"; source: "node";
nodeId?: number;
node_id?: number;
commands_tx: number; commands_tx: number;
commands_rx: number; commands_rx: number;
commands_dropped_tx: number; commands_dropped_tx: number;
@ -1067,3 +1069,12 @@ export const cancelSecureBootstrapS2 = (
type: "zwave_js/cancel_secure_bootstrap_s2", type: "zwave_js/cancel_secure_bootstrap_s2",
entry_id, entry_id,
}); });
export const fetchZwaveNeighbors = (
hass: HomeAssistant,
entry_id: string
): Promise<Record<number, number[]>> =>
hass.callWS({
type: "zwave_js/get_neighbors",
entry_id,
});

View File

@ -1,5 +1,5 @@
import { customElement, property, state } from "lit/decorators"; 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 memoizeOne from "memoize-one";
import type { HomeAssistant, Route } from "../../../../../types"; import type { HomeAssistant, Route } from "../../../../../types";
import { configTabs } from "./zwave_js-config-router"; import { configTabs } from "./zwave_js-config-router";
@ -11,8 +11,18 @@ import type {
} from "../../../../../components/chart/ha-network-graph"; } from "../../../../../components/chart/ha-network-graph";
import "../../../../../components/chart/ha-network-graph"; import "../../../../../components/chart/ha-network-graph";
import "../../../../../layouts/hass-tabs-subpage"; 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 { colorVariables } from "../../../../../resources/theme/color.globals";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
import { debounce } from "../../../../../common/util/debounce";
@customElement("zwave_js-network-visualization") @customElement("zwave_js-network-visualization")
export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) { export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@ -26,16 +36,36 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public configEntryId!: string; @property({ attribute: false }) public configEntryId!: string;
@state() private _data: NetworkData | null = null; @state() private _nodeStatuses: Record<number, ZWaveJSNodeStatus> = {};
protected async firstUpdated() { @state() private _nodeStatistics: Record<
this._data = await this._getNetworkData(); number,
ZWaveJSNodeStatisticsUpdatedMessage
> = {};
@state() private _devices: Record<string, DeviceRegistryEntry> = {};
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() { protected render() {
if (!this._data) {
return nothing;
}
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage
.hass=${this.hass} .hass=${this.hass}
@ -45,14 +75,36 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
> >
<ha-network-graph <ha-network-graph
.hass=${this.hass} .hass=${this.hass}
.data=${this._data} .data=${this._getNetworkData(
this._nodeStatuses,
this._nodeStatistics
)}
@chart-click=${this._handleChartClick} @chart-click=${this._handleChartClick}
></ha-network-graph ></ha-network-graph
></hass-tabs-subpage> ></hass-tabs-subpage>
`; `;
} }
private _getNetworkData = memoizeOne(async (): Promise<NetworkData> => { private async _fetchNetworkStatus() {
const network = await fetchZwaveNetworkStatus(this.hass!, {
entry_id: this.configEntryId,
});
const nodeStatuses: Record<number, ZWaveJSNodeStatus> = {};
network.controller.nodes.forEach((node) => {
nodeStatuses[node.node_id] = node;
});
this._nodeStatuses = nodeStatuses;
// const neighbors = await fetchZwaveNeighbors(this.hass!, this.configEntryId);
// console.log("neighbors", neighbors);
}
private _getNetworkData = memoizeOne(
(
nodeStatuses: Record<number, ZWaveJSNodeStatus>,
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>
): NetworkData => {
const nodes: NetworkNode[] = []; const nodes: NetworkNode[] = [];
const links: NetworkLink[] = []; const links: NetworkLink[] = [];
const categories = [ const categories = [
@ -66,28 +118,108 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
}, },
}, },
{ {
name: this.hass.localize("ui.panel.config.zwave_js.visualization.node"), name: this.hass.localize(
"ui.panel.config.zwave_js.visualization.node"
),
symbol: "circle", symbol: "circle",
itemStyle: { itemStyle: {
color: colorVariables["cyan-color"], 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"],
},
},
]; ];
const network = await fetchZwaveNetworkStatus(this.hass!, { if (!Object.keys(nodeStatuses).length) {
entry_id: this.configEntryId, return { nodes, links, categories };
}); }
network.controller.nodes.forEach((node) => {
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({ nodes.push({
id: String(node.node_id), id: String(node.node_id),
label: String(node.node_id), name: device?.name_by_user ?? device?.name ?? String(node.node_id),
fixed: node.is_controller_node, fixed: node.is_controller_node,
polarDistance: node.is_controller_node ? 0 : 0.5, polarDistance: node.is_controller_node
category: node.is_controller_node ? 0 : 1, ? 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"],
},
}); });
}); });
return { nodes, links, categories }; 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) { private _handleChartClick(_e: CustomEvent) {
// @TODO // @TODO

View File

@ -6249,7 +6249,9 @@
}, },
"visualization": { "visualization": {
"controller": "Controller", "controller": "Controller",
"node": "Node" "node": "Node",
"asleep_node": "Asleep Node",
"dead_node": "Dead Node"
}, },
"node_installer": { "node_installer": {
"header": "Installer Settings", "header": "Installer Settings",