diff --git a/package.json b/package.json index 7f0194ccd7..6349d3864f 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,8 @@ "superstruct": "^0.10.12", "tinykeys": "^1.1.1", "unfetch": "^4.1.0", + "vis-data": "^7.1.1", + "vis-network": "^8.5.4", "vue": "^2.6.11", "vue2-daterange-picker": "^0.5.1", "web-animations-js": "^2.3.2", diff --git a/src/data/zha.ts b/src/data/zha.ts index 41c4f0805c..5f4cb3aa18 100644 --- a/src/data/zha.ts +++ b/src/data/zha.ts @@ -7,6 +7,7 @@ export interface ZHAEntityReference extends HassEntity { } export interface ZHADevice { + available: boolean; name: string; ieee: string; nwk: string; @@ -25,6 +26,13 @@ export interface ZHADevice { area_id?: string; device_type: string; signature: any; + neighbors: Neighbor[]; +} + +export interface Neighbor { + ieee: string; + nwk: string; + lqi: number; } export interface ZHADeviceEndpoint { diff --git a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard-router.ts b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard-router.ts index 62af2ab06c..866ec1317c 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard-router.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard-router.ts @@ -42,6 +42,10 @@ class ZHAConfigDashboardRouter extends HassRouterPage { tag: "zha-add-group-page", load: () => import("./zha-add-group-page"), }, + visualization: { + tag: "zha-network-visualization-page", + load: () => import("./zha-network-visualization-page"), + }, }, }; diff --git a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts index e6025f777c..10d68ef551 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts @@ -15,7 +15,7 @@ import "../../../../../components/ha-icon-next"; import { haStyle } from "../../../../../resources/styles"; import type { HomeAssistant, Route } from "../../../../../types"; import "../../../ha-config-section"; -import { mdiNetwork, mdiFolderMultipleOutline, mdiPlus } from "@mdi/js"; +import { mdiNetwork, mdiFolderMultipleOutline, mdiPlus, mdiLan } from "@mdi/js"; import "../../../../../layouts/hass-tabs-subpage"; import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage"; import { computeRTL } from "../../../../../common/util/compute_rtl"; @@ -32,6 +32,11 @@ export const zhaTabs: PageNavigation[] = [ path: `/config/zha/groups`, iconPath: mdiFolderMultipleOutline, }, + { + translationKey: "ui.panel.config.zha.visualization.caption", + path: `/config/zha/visualization`, + iconPath: mdiLan, + }, ]; @customElement("zha-config-dashboard") 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 new file mode 100644 index 0000000000..8f3dbd63a5 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts @@ -0,0 +1,214 @@ +import { + css, + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, + PropertyValues, + query, +} from "lit-element"; + +import { navigate } from "../../../../../common/navigate"; +import { fetchDevices, ZHADevice } from "../../../../../data/zha"; +import "../../../../../layouts/hass-subpage"; +import type { HomeAssistant } from "../../../../../types"; +import { Network, Edge, Node, EdgeOptions } from "vis-network"; + +@customElement("zha-network-visualization-page") +export class ZHANetworkVisualizationPage extends LitElement { + @property({ type: Object }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow!: boolean; + + @query("#visualization", true) + private _visualization?: HTMLElement; + + @internalProperty() + private _devices: Map = new Map(); + + @internalProperty() + private _network?: Network; + + protected firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + if (this.hass) { + this._fetchData(); + } + this._network = new Network( + this._visualization!, + {}, + { + autoResize: true, + height: window.innerHeight + "px", + width: window.innerWidth + "px", + layout: { + improvedLayout: true, + }, + physics: { + barnesHut: { + springConstant: 0, + avoidOverlap: 10, + damping: 0.09, + }, + }, + nodes: { + font: { + multi: "html", + }, + }, + edges: { + smooth: { + enabled: true, + type: "continuous", + forceDirection: "none", + roundness: 0.6, + }, + }, + } + ); + this._network.on("doubleClick", (properties) => { + const ieee = properties.nodes[0]; + if (ieee) { + const device = this._devices.get(ieee); + if (device) { + navigate( + this, + `/config/devices/device/${device.device_reg_id}`, + false + ); + } + } + }); + } + + protected render() { + return html` + +
+
+ `; + } + + private async _fetchData() { + const devices = await fetchDevices(this.hass!); + this._devices = new Map( + devices.map((device: ZHADevice) => [device.ieee, device]) + ); + this._updateDevices(devices); + } + + private _updateDevices(devices: ZHADevice[]) { + const nodes: Node[] = []; + const edges: Edge[] = []; + + devices.forEach((device) => { + nodes.push({ + id: device.ieee, + label: this._buildLabel(device), + shape: this._getShape(device), + mass: this._getMass(device), + }); + if (device.neighbors && device.neighbors.length > 0) { + device.neighbors.forEach((neighbor) => { + const idx = edges.findIndex(function (e) { + return device.ieee === e.to && neighbor.ieee === e.from; + }); + if (idx === -1) { + edges.push({ + from: device.ieee, + to: neighbor.ieee, + label: neighbor.lqi + "", + color: this._getLQI(neighbor.lqi), + }); + } else { + edges[idx].color = this._getLQI( + (parseInt(edges[idx].label!) + neighbor.lqi) / 2 + ); + edges[idx].label += "/" + neighbor.lqi; + } + }); + } + }); + + this._network?.setData({ nodes: nodes, edges: edges }); + } + + private _getLQI(lqi: number): EdgeOptions["color"] { + if (lqi > 192) { + return { color: "#17ab00", highlight: "#17ab00" }; + } + if (lqi > 128) { + return { color: "#e6b402", highlight: "#e6b402" }; + } + if (lqi > 80) { + return { color: "#fc4c4c", highlight: "#fc4c4c" }; + } + return { color: "#bfbfbf", highlight: "#bfbfbf" }; + } + + private _getMass(device: ZHADevice): number { + if (device.device_type === "Coordinator") { + return 2; + } + if (device.device_type === "Router") { + return 4; + } + return 5; + } + + private _getShape(device: ZHADevice): string { + if (device.device_type === "Coordinator") { + return "box"; + } + if (device.device_type === "Router") { + return "ellipse"; + } + return "circle"; + } + + private _buildLabel(device: ZHADevice): string { + let label = + device.user_given_name !== null + ? `${device.user_given_name}\n` + : ""; + label += `IEEE: ${device.ieee}`; + label += `\nDevice Type: ${device.device_type.replace("_", " ")}`; + if (device.nwk != null) { + label += `\nNWK: ${device.nwk}`; + } + if (device.manufacturer != null && device.model != null) { + label += `\nDevice: ${device.manufacturer} ${device.model}`; + } else { + label += "\nDevice is not in 'zigbee.db'"; + } + if (!device.available) { + label += "\nDevice is Offline"; + } + return label; + } + + static get styles(): CSSResult[] { + return [ + css` + .header { + font-family: var(--paper-font-display1_-_font-family); + -webkit-font-smoothing: var( + --paper-font-display1_-_-webkit-font-smoothing + ); + font-size: var(--paper-font-display1_-_font-size); + font-weight: var(--paper-font-display1_-_font-weight); + letter-spacing: var(--paper-font-display1_-_letter-spacing); + line-height: var(--paper-font-display1_-_line-height); + opacity: var(--dark-primary-opacity); + } + `, + ]; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 9968c06ea5..f3fb442eac 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2215,6 +2215,10 @@ "create": "Create Group", "creating_group": "Creating Group" }, + "visualization": { + "header": "Network Visualization", + "caption": "Visualization" + }, "group_binding": { "header": "Group Binding", "introduction": "Bind and unbind groups.", diff --git a/yarn.lock b/yarn.lock index 7b2948ee1c..06b93edb5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13567,6 +13567,16 @@ vinyl@^2.0.0, vinyl@^2.1.0: remove-trailing-separator "^1.0.1" replace-ext "^1.0.0" +vis-data@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/vis-data/-/vis-data-7.1.1.tgz#a38b38efcc0b721181ddaa4f4359edfd75623825" + integrity sha512-Z5+caySDqoKL9yxbI3c/CKmUcSvROSZstuvwxbOsUpdxHpxFYEUgxC1EH4lSB1ykEaM54MVMM1UcwB9oNaWFlw== + +vis-network@^8.5.4: + version "8.5.4" + resolved "https://registry.yarnpkg.com/vis-network/-/vis-network-8.5.4.tgz#6979a626c28f29e981adf535a8e6c25503c26f7f" + integrity sha512-KeYHlTZpbPHS6868MHnMtRXDTmKA0YwQQl/mC5cBiICGH67ilzOqkyWObAMyeo8b8Z/6pTfFJEu9g70EvWqOYA== + vm-browserify@0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73"