diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index 11bcefa4cbc4..c3f3919e1f32 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -244,6 +244,41 @@ export interface ZWaveJSControllerStatisticsUpdatedMessage { timeout_callback: number; } +export enum RssiError { + NotAvailable = 127, + ReceiverSaturated = 126, + NoSignalDetected = 125, +} + +export enum ProtocolDataRate { + ZWave_9k6 = 0x01, + ZWave_40k = 0x02, + ZWave_100k = 0x03, + LongRange_100k = 0x04, +} + +export interface ZWaveJSNodeStatisticsUpdatedMessage { + event: "statistics updated"; + source: "node"; + commands_tx: number; + commands_rx: number; + commands_dropped_tx: number; + commands_dropped_rx: number; + timeout_response: number; + rtt: number | null; + rssi: RssiError | number | null; + lwr: ZWaveJSRouteStatistics | null; + nlwr: ZWaveJSRouteStatistics | null; +} + +export interface ZWaveJSRouteStatistics { + protocol_data_rate: number; + repeaters: string[]; + rssi: RssiError | number | null; + repeater_rssi: (RssiError | number)[]; + route_failed_between: [string, string] | null; +} + export interface ZWaveJSRemovedNode { node_id: number; manufacturer: string; @@ -597,6 +632,19 @@ export const subscribeZwaveControllerStatistics = ( } ); +export const subscribeZwaveNodeStatistics = ( + hass: HomeAssistant, + device_id: string, + callbackFunction: (message: ZWaveJSNodeStatisticsUpdatedMessage) => void +): Promise => + hass.connection.subscribeMessage( + (message: any) => callbackFunction(message), + { + type: "zwave_js/subscribe_node_statistics", + device_id, + } + ); + export const getZwaveJsIdentifiersFromDevice = ( device: DeviceRegistryEntry ): ZWaveJSNodeIdentifiers | undefined => { diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts index 004a2a6c9590..82694f4fa761 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts @@ -3,6 +3,7 @@ import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; import { fetchZwaveNodeStatus } from "../../../../../../data/zwave_js"; import type { HomeAssistant } from "../../../../../../types"; import { showZWaveJSHealNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node"; +import { showZWaveJSNodeStatisticsDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics"; import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node"; import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node"; import type { DeviceAction } from "../../../ha-config-device-page"; @@ -64,5 +65,14 @@ export const getZwaveDeviceActions = async ( device_id: device.id, }), }, + { + label: hass.localize( + "ui.panel.config.zwave_js.device_info.node_statistics" + ), + action: () => + showZWaveJSNodeStatisticsDialog(el, { + device: device, + }), + }, ]; }; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-node-statistics.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-node-statistics.ts new file mode 100644 index 000000000000..b68eb367c0c0 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-node-statistics.ts @@ -0,0 +1,477 @@ +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import "@material/mwc-list/mwc-list"; +import "@material/mwc-list/mwc-list-item"; +import "../../../../../components/ha-expansion-panel"; +import "../../../../../components/ha-help-tooltip"; +import "../../../../../components/ha-svg-icon"; +import { mdiSwapHorizontal } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { + DeviceRegistryEntry, + computeDeviceName, + subscribeDeviceRegistry, +} from "../../../../../data/device_registry"; +import { + subscribeZwaveNodeStatistics, + ProtocolDataRate, + ZWaveJSNodeStatisticsUpdatedMessage, + ZWaveJSRouteStatistics, + RssiError, +} from "../../../../../data/zwave_js"; +import { haStyleDialog } from "../../../../../resources/styles"; +import { HomeAssistant } from "../../../../../types"; +import { ZWaveJSNodeStatisticsDialogParams } from "./show-dialog-zwave_js-node-statistics"; +import { createCloseHeading } from "../../../../../components/ha-dialog"; + +type WorkingRouteStatistics = + | (ZWaveJSRouteStatistics & { + repeater_rssi_table?: TemplateResult; + rssi_translated?: TemplateResult | string; + route_failed_between_translated?: [string, string]; + }) + | undefined; + +@customElement("dialog-zwave_js-node-statistics") +class DialogZWaveJSNodeStatistics extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private device?: DeviceRegistryEntry; + + @state() private _nodeStatistics?: ZWaveJSNodeStatisticsUpdatedMessage & { + rssi_translated?: TemplateResult | string; + }; + + @state() private _deviceIDsToName: { [key: string]: string } = {}; + + @state() private _workingRoutes: { + lwr?: WorkingRouteStatistics; + nlwr?: WorkingRouteStatistics; + } = {}; + + private _subscribedNodeStatistics?: Promise; + + private _subscribedDeviceRegistry?: UnsubscribeFunc; + + public showDialog(params: ZWaveJSNodeStatisticsDialogParams): void { + this.device = params.device; + this._subscribeDeviceRegistry(); + this._subscribeNodeStatistics(); + } + + public closeDialog(): void { + this._nodeStatistics = undefined; + this.device = undefined; + + this._unsubscribe(); + + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this.device) { + return html``; + } + + return html` + + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_tx.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_tx.tooltip" + )} + + ${this._nodeStatistics?.commands_tx} + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_rx.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_rx.tooltip" + )} + + ${this._nodeStatistics?.commands_rx} + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_dropped_tx.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_dropped_tx.tooltip" + )} + + ${this._nodeStatistics?.commands_dropped_tx} + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_dropped_rx.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_dropped_rx.tooltip" + )} + + ${this._nodeStatistics?.commands_dropped_rx} + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.timeout_response.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.timeout_response.tooltip" + )} + + ${this._nodeStatistics?.timeout_response} + + ${this._nodeStatistics?.rtt + ? html` + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.rtt.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.rtt.tooltip" + )} + + ${this._nodeStatistics.rtt} + ` + : ``} + ${this._nodeStatistics?.rssi_translated + ? html` + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.rssi.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.rssi.tooltip" + )} + + ${this._nodeStatistics.rssi_translated} + ` + : ``} + + ${Object.entries(this._workingRoutes).map(([wrKey, wrValue]) => + wrValue + ? html` + +
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.protocol.label" + )} + + ${this.hass.localize( + `ui.panel.config.zwave_js.route_statistics.protocol.protocol_data_rate.${ + ProtocolDataRate[wrValue.protocol_data_rate] + }` + )} +
+
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.data_rate.label" + )} + + ${this.hass.localize( + `ui.panel.config.zwave_js.route_statistics.data_rate.protocol_data_rate.${ + ProtocolDataRate[wrValue.protocol_data_rate] + }` + )} +
+ ${wrValue.rssi_translated + ? html`
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.rssi.label" + )} + + ${wrValue.rssi_translated} +
` + : ``} +
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.route_failed_between.label" + )} + + + ${wrValue.route_failed_between_translated + ? html`${wrValue + .route_failed_between_translated[0]}${wrValue.route_failed_between_translated[1]}` + : this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.route_failed_between.not_applicable" + )} + +
+
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.repeaters.label" + )} + + ${wrValue.repeater_rssi_table + ? html`
+ ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.repeaters.repeaters" + )}: + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.repeaters.rssi" + )}: +
+ ${wrValue.repeater_rssi_table}` + : html`${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.repeaters.direct" + )}`}
+
+
+ ` + : `` + )} +
+ `; + } + + private _computeRSSI( + rssi: number, + includeUnit: boolean + ): TemplateResult | string { + if (Object.values(RssiError).includes(rssi)) { + return html``; + } + if (includeUnit) { + return `${rssi} + ${this.hass.localize("ui.panel.config.zwave_js.rssi.unit")}`; + } + return rssi.toString(); + } + + private _computeDeviceNameById(device_id: string): "unknown device" | string { + if (!this._deviceIDsToName) { + return "unknown device"; + } + const device = this._deviceIDsToName[device_id]; + if (!device) { + return "unknown device"; + } + + return this._deviceIDsToName[device_id] || "unknown device"; + } + + private _subscribeNodeStatistics(): void { + if (!this.hass) { + return; + } + this._subscribedNodeStatistics = subscribeZwaveNodeStatistics( + this.hass, + this.device!.id, + (message: ZWaveJSNodeStatisticsUpdatedMessage) => { + this._nodeStatistics = { + ...message, + rssi_translated: message.rssi + ? this._computeRSSI(message.rssi, false) + : undefined, + }; + + const workingRoutesValueMap: [ + string, + WorkingRouteStatistics | null | undefined + ][] = [ + ["lwr", this._nodeStatistics?.lwr], + ["nlwr", this._nodeStatistics?.nlwr], + ]; + + const workingRoutes: { + lwr?: WorkingRouteStatistics; + nlwr?: WorkingRouteStatistics; + } = {}; + workingRoutesValueMap.forEach(([wrKey, wrValue]) => { + workingRoutes[wrKey] = wrValue; + + if (wrValue) { + if (wrValue.rssi) { + wrValue.rssi_translated = this._computeRSSI(wrValue.rssi, true); + } + + if (wrValue.route_failed_between) { + wrValue.route_failed_between_translated = [ + this._computeDeviceNameById(wrValue.route_failed_between[0]), + this._computeDeviceNameById(wrValue.route_failed_between[1]), + ]; + } + + if (wrValue.repeaters && wrValue.repeaters.length) { + wrValue.repeater_rssi_table = html`${wrValue.repeaters.map( + (_, idx) => + html`
+ ${this._computeDeviceNameById( + wrValue.repeaters[idx] + )}: + ${this._computeRSSI( + wrValue.repeater_rssi[idx], + true + )} +
` + )}`; + } + } + }); + this._workingRoutes = workingRoutes; + } + ); + } + + private _subscribeDeviceRegistry(): void { + if (!this.hass) { + return; + } + this._subscribedDeviceRegistry = subscribeDeviceRegistry( + this.hass.connection, + (devices: DeviceRegistryEntry[]) => { + const devicesIdToName = {}; + devices.forEach((device) => { + devicesIdToName[device.id] = computeDeviceName(device, this.hass); + }); + this._deviceIDsToName = devicesIdToName; + } + ); + } + + private _unsubscribe(): void { + if (this._subscribedNodeStatistics) { + this._subscribedNodeStatistics.then((unsub) => unsub()); + this._subscribedNodeStatistics = undefined; + } + if (this._subscribedDeviceRegistry) { + this._subscribedDeviceRegistry(); + this._subscribedDeviceRegistry = undefined; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + mwc-list-item { + height: 60px; + } + + .row { + display: flex; + justify-content: space-between; + } + + .table { + display: table; + } + + .key-cell { + display: table-cell; + padding-right: 5px; + } + + .value-cell { + display: table-cell; + padding-left: 5px; + } + + span[slot="meta"] { + font-size: 0.95em; + color: var(--primary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-zwave_js-node-statistics": DialogZWaveJSNodeStatistics; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics.ts new file mode 100644 index 000000000000..ec48d7f02c54 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics.ts @@ -0,0 +1,20 @@ +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { DeviceRegistryEntry } from "../../../../../data/device_registry"; + +export interface ZWaveJSNodeStatisticsDialogParams { + device: DeviceRegistryEntry; +} + +export const loadNodeStatisticsDialog = () => + import("./dialog-zwave_js-node-statistics"); + +export const showZWaveJSNodeStatisticsDialog = ( + element: HTMLElement, + nodeStatisticsDialogParams: ZWaveJSNodeStatisticsDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-zwave_js-node-statistics", + dialogImport: loadNodeStatisticsDialog, + dialogParams: nodeStatisticsDialogParams, + }); +}; diff --git a/src/translations/en.json b/src/translations/en.json index 2d3f00c4edb2..6adbfbdb0289 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3101,7 +3101,87 @@ "highest_security": "Highest Security", "unknown": "Unknown", "zwave_plus": "Z-Wave Plus", - "zwave_plus_version": "Version {version}" + "zwave_plus_version": "Version {version}", + "node_statistics": "Show Device Statistics" + }, + "node_statistics": { + "title": "Device Statistics", + "commands_tx": { + "label": "Commands TX", + "tooltip": "# of commands successfully sent to the node" + }, + "commands_rx": { + "label": "Commands RX", + "tooltip": "# of commands received from the node, including responses to sent commands" + }, + "commands_dropped_tx": { + "label": "Commands Dropped TX", + "tooltip": "# of outgoing commands that were dropped because they could not be sent" + }, + "commands_dropped_rx": { + "label": "Commands Dropped RX", + "tooltip": "# of commands from the node that were dropped by the host" + }, + "timeout_response": { + "label": "Timeout Response", + "tooltip": "# of Get-type commands where the node's response did not come in time" + }, + "rtt": { + "label": "RTT", + "tooltip": "Average round-trip-time in ms of commands to this node" + }, + "rssi": { + "label": "RSSI", + "tooltip": "Average RSSI in dBm of frames received by this node" + }, + "lwr": "Last Working Route", + "nlwr": "Next to Last Working Route" + }, + "route_statistics": { + "protocol": { + "label": "Protocol", + "tooltip": "The protocol for this route", + "protocol_data_rate": { + "ZWave_9k6": "Z-Wave", + "ZWave_40k": "Z-Wave", + "ZWave_100k": "Z-Wave", + "LongRange_100k": "Z-Wave Long Range" + } + }, + "data_rate": { + "label": "Data Rate", + "tooltip": "The used data rate for this route", + "protocol_data_rate": { + "ZWave_9k6": "9.6 kbps", + "ZWave_40k": "40 kbps", + "ZWave_100k": "100 kbps", + "LongRange_100k": "100 kbps" + } + }, + "repeaters": { + "label": "Repeaters + RSSI", + "tooltip": "Which nodes are repeaters for this route and their RSSI", + "repeaters": "Repeater Device", + "rssi": "RSSI", + "direct": "None, direct connection" + }, + "rssi": { + "label": "RSSI", + "tooltip": "The RSSI of the ACK frame received by the controller" + }, + "route_failed_between": { + "label": "Route Failed Between", + "tooltip": "The nodes between which the transmission failed most recently", + "not_applicable": "N/A" + } + }, + "rssi": { + "unit": "dBm", + "rssi_error": { + "NotAvailable": "Not available", + "ReceiverSaturated": "Receiver saturated", + "NoSignalDetected": "No signal detected" + } }, "node_config": { "header": "Z-Wave Device Configuration",