diff --git a/docs/api/controller.md b/docs/api/controller.md index 289eb5d20cfd..17fdf3ecfe57 100644 --- a/docs/api/controller.md +++ b/docs/api/controller.md @@ -269,6 +269,71 @@ To get around this: 2. Batch all `getNodeNeighbors` requests together 3. Turn the radio back on with `controller.toggleRF(true`) +### `getKnownLifelineRoutes` + +The routing table of the controller is stored in its memory and not easily accessible during normal operation. Z-Wave JS gets around this by keeping statistics for each node that include the last used routes, the used repeaters, procotol and speed, as well as RSSI readings. This information can be read using + +```ts +getKnownLifelineRoutes(): ReadonlyMap +``` + +This has some limitations: + +- The information is dynamically built using TX status reports and may not be accurate at all times. +- It may not be available immediately after startup or at all if the controller doesn't support this feature. +- It only includes information about the routes between the controller and nodes, not between individual nodes. + +> [!NOTE] To keep information returned by this method updated, subscribe to each node's `"statistics"` event and use the included information. + +The returned objects have the following shape: + + + +```ts +interface LifelineRoutes { + /** The last working route from the controller to this node. */ + lwr?: RouteStatistics; + /** The next to last working route from the controller to this node. */ + nlwr?: RouteStatistics; +} +``` + + + +```ts +interface RouteStatistics { + /** The protocol and used data rate for this route */ + protocolDataRate: ProtocolDataRate; + /** Which nodes are repeaters for this route */ + repeaters: number[]; + + /** The RSSI of the ACK frame received by the controller */ + rssi?: RSSI; + /** + * The RSSI of the ACK frame received by each repeater. + * If this is set, it has the same length as the repeaters array. + */ + repeaterRSSI?: RSSI[]; + + /** + * The node IDs of the nodes between which the transmission failed most recently. + * Is only set if there recently was a transmission failure. + */ + routeFailedBetween?: [number, number]; +} +``` + + + +```ts +declare enum ProtocolDataRate { + ZWave_9k6 = 1, + ZWave_40k = 2, + ZWave_100k = 3, + LongRange_100k = 4, +} +``` + ### `healNode` ```ts diff --git a/docs/api/node.md b/docs/api/node.md index 1efbec5b192d..899e13ce88f7 100644 --- a/docs/api/node.md +++ b/docs/api/node.md @@ -1175,5 +1175,41 @@ interface NodeStatistics { * Consecutive measurements are combined using an exponential moving average. */ rtt?: number; + + /** + * Average RSSI of frames received by this node in dBm. + * Consecutive non-error measurements are combined using an exponential moving average. + */ + rssi?: RSSI; + + /** The last working route from the controller to this node. */ + lwr?: RouteStatistics; + /** The next to last working route from the controller to this node. */ + nlwr?: RouteStatistics; +} +``` + + + +```ts +interface RouteStatistics { + /** The protocol and used data rate for this route */ + protocolDataRate: ProtocolDataRate; + /** Which nodes are repeaters for this route */ + repeaters: number[]; + + /** The RSSI of the ACK frame received by the controller */ + rssi?: RSSI; + /** + * The RSSI of the ACK frame received by each repeater. + * If this is set, it has the same length as the repeaters array. + */ + repeaterRSSI?: RSSI[]; + + /** + * The node IDs of the nodes between which the transmission failed most recently. + * Is only set if there recently was a transmission failure. + */ + routeFailedBetween?: [number, number]; } ``` diff --git a/packages/zwave-js/src/Controller.ts b/packages/zwave-js/src/Controller.ts index 50706e1a38da..d2bf1f816235 100644 --- a/packages/zwave-js/src/Controller.ts +++ b/packages/zwave-js/src/Controller.ts @@ -7,6 +7,11 @@ export type { export type { ControllerStatistics } from "./lib/controller/ControllerStatistics"; export { ZWaveFeature } from "./lib/controller/Features"; export * from "./lib/controller/Inclusion"; -export { RSSI, RssiError, TXReport } from "./lib/controller/SendDataShared"; +export { + isRssiError, + RSSI, + RssiError, + TXReport, +} from "./lib/controller/SendDataShared"; export type { ZWaveLibraryTypes } from "./lib/controller/ZWaveLibraryTypes"; export { RFRegion } from "./lib/serialapi/misc/SerialAPISetupMessages"; diff --git a/packages/zwave-js/src/Node.ts b/packages/zwave-js/src/Node.ts index 305c20625ce9..5d9bf3e7a9a9 100644 --- a/packages/zwave-js/src/Node.ts +++ b/packages/zwave-js/src/Node.ts @@ -4,16 +4,21 @@ export { NodeType, NODE_ID_BROADCAST, NODE_ID_MAX, + ProtocolDataRate, ProtocolVersion, } from "@zwave-js/core"; export { DeviceClass } from "./lib/node/DeviceClass"; export { Endpoint } from "./lib/node/Endpoint"; export { ZWaveNode } from "./lib/node/Node"; -export type { NodeStatistics } from "./lib/node/NodeStatistics"; +export type { + NodeStatistics, + RouteStatistics, +} from "./lib/node/NodeStatistics"; export { InterviewStage, LifelineHealthCheckResult, LifelineHealthCheckSummary, + LifelineRoutes, NodeInterviewFailedEventArgs, NodeStatus, RefreshInfoOptions, diff --git a/packages/zwave-js/src/lib/controller/Controller.ts b/packages/zwave-js/src/lib/controller/Controller.ts index 9bc3d89adb3f..1afce1747376 100644 --- a/packages/zwave-js/src/lib/controller/Controller.ts +++ b/packages/zwave-js/src/lib/controller/Controller.ts @@ -90,7 +90,7 @@ import type { Message } from "../message/Message"; import type { SuccessIndicator } from "../message/SuccessIndicator"; import { DeviceClass } from "../node/DeviceClass"; import { ZWaveNode } from "../node/Node"; -import { InterviewStage, NodeStatus } from "../node/Types"; +import { InterviewStage, LifelineRoutes, NodeStatus } from "../node/Types"; import { VirtualNode } from "../node/VirtualNode"; import { GetBackgroundRSSIRequest, @@ -4211,6 +4211,26 @@ ${associatedNodes.join(", ")}`, } } + /** + * Returns the known routes the controller will use to communicate with the nodes. + * + * This information is dynamically built using TX status reports and may not be accurate at all times. + * Also, it may not be available immediately after startup or at all if the controller doesn't support this feature. + * + * **Note:** To keep information returned by this method updated, use the information contained in each node's `"statistics"` event. + */ + public getKnownLifelineRoutes(): ReadonlyMap { + const ret = new Map(); + for (const node of this.nodes.values()) { + if (node.isControllerNode) continue; + ret.set(node.id, { + lwr: node.statistics.lwr, + nlwr: node.statistics.nlwr, + }); + } + return ret; + } + /** * @internal * Serializes the controller information and all nodes to store them in a cache. diff --git a/packages/zwave-js/src/lib/controller/SendDataShared.ts b/packages/zwave-js/src/lib/controller/SendDataShared.ts index e9a4f42cfb6c..ff5c50c2e7c8 100644 --- a/packages/zwave-js/src/lib/controller/SendDataShared.ts +++ b/packages/zwave-js/src/lib/controller/SendDataShared.ts @@ -82,6 +82,10 @@ export enum RssiError { NoSignalDetected = 125, } +export function isRssiError(rssi: RSSI): rssi is RssiError { + return rssi >= RssiError.NoSignalDetected; +} + // const RSSI_RESERVED_START = 11; /** A number between -128 and +124 dBm or one of the special values in {@link RssiError} indicating an error */ diff --git a/packages/zwave-js/src/lib/driver/Driver.ts b/packages/zwave-js/src/lib/driver/Driver.ts index 9a800b14673c..5c259ce0af5a 100644 --- a/packages/zwave-js/src/lib/driver/Driver.ts +++ b/packages/zwave-js/src/lib/driver/Driver.ts @@ -43,7 +43,6 @@ import { createDeferredPromise, DeferredPromise, } from "alcalzone-shared/deferred-promise"; -import { roundTo } from "alcalzone-shared/math"; import { isArray, isObject } from "alcalzone-shared/typeguards"; import { randomBytes } from "crypto"; import type { EventEmitter } from "events"; @@ -3263,25 +3262,13 @@ ${handlers.length} left`, node.incrementStatistics("commandsDroppedTX"); } else { node.incrementStatistics("commandsTX"); - if (msg.rtt) { - const rttMs = msg.rtt / 1e6; - node.updateStatistics((current) => ({ - ...current, - rtt: - current.rtt != undefined - ? roundTo( - current.rtt * 0.9 + rttMs * 0.1, - 1, - ) - : roundTo(rttMs, 1), - })); - } + node.updateRTT(msg); } // Notify listeners about the status report if one was received if (hasTXReport(result)) { options.onTXReport?.(result.txReport); - // TODO: Update statistics based on the TX report + node.updateRouteStatistics(result.txReport); } } diff --git a/packages/zwave-js/src/lib/node/Node.ts b/packages/zwave-js/src/lib/node/Node.ts index e0e81c04aa90..502ac25fce57 100644 --- a/packages/zwave-js/src/lib/node/Node.ts +++ b/packages/zwave-js/src/lib/node/Node.ts @@ -57,10 +57,12 @@ import { stringify, TypedEventEmitter, } from "@zwave-js/shared"; +import { roundTo } from "alcalzone-shared/math"; import { padStart } from "alcalzone-shared/strings"; import { isArray, isObject } from "alcalzone-shared/typeguards"; import { randomBytes } from "crypto"; import { EventEmitter } from "events"; +import type { Message } from "../.."; import { PowerlevelCCTestNodeReport } from "../commandclass"; import type { CCAPI, @@ -152,7 +154,12 @@ import { GetNodeProtocolInfoRequest, GetNodeProtocolInfoResponse, } from "../controller/GetNodeProtocolInfoMessages"; -import { RSSI, RssiError, TXReport } from "../controller/SendDataShared"; +import { + isRssiError, + RSSI, + RssiError, + TXReport, +} from "../controller/SendDataShared"; import type { Driver, SendCommandOptions } from "../driver/Driver"; import { Extended, interpretEx } from "../driver/StateMachineShared"; import type { StatisticsEventCallbacksWithSelf } from "../driver/Statistics"; @@ -169,7 +176,12 @@ import { createNodeReadyMachine, NodeReadyInterpreter, } from "./NodeReadyMachine"; -import { NodeStatistics, NodeStatisticsHost } from "./NodeStatistics"; +import { + NodeStatistics, + NodeStatisticsHost, + RouteStatistics, + routeStatisticsEquals, +} from "./NodeStatistics"; import { createNodeStatusMachine, NodeStatusInterpreter, @@ -4484,4 +4496,65 @@ ${formatRouteHealthCheckSummary(this.id, otherNode.id, summary)}`, return summary; } + + /** + * Updates the average RTT of this node + * @internal + */ + public updateRTT(sentMessage: Message): void { + if (sentMessage.rtt) { + const rttMs = sentMessage.rtt / 1e6; + this.updateStatistics((current) => ({ + ...current, + rtt: + current.rtt != undefined + ? roundTo(current.rtt * 0.75 + rttMs * 0.25, 1) + : roundTo(rttMs, 1), + })); + } + } + + /** + * Updates route/transmission statistics for this node + * @internal + */ + public updateRouteStatistics(txReport: TXReport): void { + this.updateStatistics((current) => { + const ret = { ...current }; + // Update ACK RSSI + if (txReport.ackRSSI != undefined) { + ret.rssi = + ret.rssi == undefined || isRssiError(txReport.ackRSSI) + ? txReport.ackRSSI + : Math.round(ret.rssi * 0.75 + txReport.ackRSSI * 0.25); + } + + // Update the LWR's statistics + const newStats: RouteStatistics = { + protocolDataRate: txReport.routeSpeed, + repeaters: (txReport.repeaterNodeIds ?? []) as number[], + rssi: + txReport.ackRSSI ?? ret.lwr?.rssi ?? RssiError.NotAvailable, + }; + if (txReport.ackRepeaterRSSI != undefined) { + newStats.repeaterRSSI = txReport.ackRepeaterRSSI as number[]; + } + if ( + txReport.failedRouteLastFunctionalNodeId && + txReport.failedRouteFirstNonFunctionalNodeId + ) { + newStats.routeFailedBetween = [ + txReport.failedRouteLastFunctionalNodeId, + txReport.failedRouteFirstNonFunctionalNodeId, + ]; + } + + if (ret.lwr && !routeStatisticsEquals(ret.lwr, newStats)) { + // The old LWR becomes the NLWR + ret.nlwr = ret.lwr; + } + ret.lwr = newStats; + return ret; + }); + } } diff --git a/packages/zwave-js/src/lib/node/NodeStatistics.ts b/packages/zwave-js/src/lib/node/NodeStatistics.ts index a75bb1602b99..fb61a098ec5c 100644 --- a/packages/zwave-js/src/lib/node/NodeStatistics.ts +++ b/packages/zwave-js/src/lib/node/NodeStatistics.ts @@ -1,3 +1,5 @@ +import type { ProtocolDataRate } from "@zwave-js/core"; +import type { RSSI } from "../controller/SendDataShared"; import { StatisticsHost } from "../driver/Statistics"; export class NodeStatisticsHost extends StatisticsHost { @@ -36,4 +38,47 @@ export interface NodeStatistics { * Consecutive measurements are combined using an exponential moving average. */ rtt?: number; + + /** + * Average RSSI of frames received by this node in dBm. + * Consecutive non-error measurements are combined using an exponential moving average. + */ + rssi?: RSSI; + + /** The last working route from the controller to this node. */ + lwr?: RouteStatistics; + /** The next to last working route from the controller to this node. */ + nlwr?: RouteStatistics; +} + +export interface RouteStatistics { + /** The protocol and used data rate for this route */ + protocolDataRate: ProtocolDataRate; + /** Which nodes are repeaters for this route */ + repeaters: number[]; + + /** The RSSI of the ACK frame received by the controller */ + rssi?: RSSI; + /** + * The RSSI of the ACK frame received by each repeater. + * If this is set, it has the same length as the repeaters array. + */ + repeaterRSSI?: RSSI[]; + + /** + * The node IDs of the nodes between which the transmission failed most recently. + * Is only set if there recently was a transmission failure. + */ + routeFailedBetween?: [number, number]; +} + +/** Checks if the given route statistics belong to the same route */ +export function routeStatisticsEquals( + r1: RouteStatistics, + r2: RouteStatistics, +): boolean { + if (r1.repeaters.length !== r2.repeaters.length) return false; + if (!r1.repeaters.every((node) => r2.repeaters.includes(node))) + return false; + return true; } diff --git a/packages/zwave-js/src/lib/node/Types.ts b/packages/zwave-js/src/lib/node/Types.ts index 5bbea14b9a1b..01a8958a9771 100644 --- a/packages/zwave-js/src/lib/node/Types.ts +++ b/packages/zwave-js/src/lib/node/Types.ts @@ -14,6 +14,7 @@ import type { ZWaveNotificationCallbackParams_PowerlevelCC, } from "../commandclass/PowerlevelCC"; import type { ZWaveNode } from "./Node"; +import type { RouteStatistics } from "./NodeStatistics"; export interface TranslatedValueID extends ValueID { commandClassName: string; @@ -294,3 +295,11 @@ export interface RefreshInfoOptions { */ waitForWakeup?: boolean; } + +/** The last known routes between the controller and a node */ +export interface LifelineRoutes { + /** The last working route from the controller to this node. */ + lwr?: RouteStatistics; + /** The next to last working route from the controller to this node. */ + nlwr?: RouteStatistics; +}