diff --git a/packages/cc/api.md b/packages/cc/api.md index a2938f08e43b..ea09dc7dff8a 100644 --- a/packages/cc/api.md +++ b/packages/cc/api.md @@ -4130,7 +4130,8 @@ export class ConfigurationCCNameGet extends ConfigurationCC { // // @public (undocumented) export class ConfigurationCCNameReport extends ConfigurationCC { - constructor(host: ZWaveHost_2, options: CommandClassDeserializationOptions); + // Warning: (ae-forgotten-export) The symbol "ConfigurationCCNameReportOptions" needs to be exported by the entry point index.d.ts + constructor(host: ZWaveHost_2, options: CommandClassDeserializationOptions | ConfigurationCCNameReportOptions); // (undocumented) expectMoreMessages(): boolean; // (undocumented) @@ -4138,13 +4139,15 @@ export class ConfigurationCCNameReport extends ConfigurationCC { // (undocumented) mergePartialCCs(applHost: ZWaveApplicationHost_2, partials: ConfigurationCCNameReport[]): void; // (undocumented) - get name(): string; + name: string; // (undocumented) - get parameter(): number; + readonly parameter: number; // (undocumented) persistValues(applHost: ZWaveApplicationHost_2): boolean; // (undocumented) - get reportsToFollow(): number; + readonly reportsToFollow: number; + // (undocumented) + serialize(): Buffer; // (undocumented) toLogEntry(applHost: ZWaveApplicationHost_2): MessageOrCCLogEntry_2; } @@ -6767,6 +6770,11 @@ export function getImplementedVersion(cc: T | CommandCla // @public export function getImplementedVersionStatic>(classConstructor: T): number; +// Warning: (ae-missing-release-tag) "getInnermostCommandClass" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function getInnermostCommandClass(cc: CommandClass): CommandClass; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@publicAPI" is not defined in this configuration // Warning: (ae-missing-release-tag) "getManufacturerId" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/packages/cc/src/cc/ConfigurationCC.ts b/packages/cc/src/cc/ConfigurationCC.ts index 2465cbbf30db..788b8ec1e7fb 100644 --- a/packages/cc/src/cc/ConfigurationCC.ts +++ b/packages/cc/src/cc/ConfigurationCC.ts @@ -2077,24 +2077,43 @@ export class ConfigurationCCBulkGet extends ConfigurationCC { } } +export interface ConfigurationCCNameReportOptions extends CCCommandOptions { + parameter: number; + name: string; + reportsToFollow: number; +} + @CCCommand(ConfigurationCommand.NameReport) export class ConfigurationCCNameReport extends ConfigurationCC { public constructor( host: ZWaveHost, - options: CommandClassDeserializationOptions, + options: + | CommandClassDeserializationOptions + | ConfigurationCCNameReportOptions, ) { super(host, options); - // Parameter and # of reports must be present - validatePayload(this.payload.length >= 3); - this._parameter = this.payload.readUInt16BE(0); - this._reportsToFollow = this.payload[2]; - if (this._reportsToFollow > 0) { - // If more reports follow, the info must at least be one byte - validatePayload(this.payload.length >= 4); + + if (gotDeserializationOptions(options)) { + // Parameter and # of reports must be present + validatePayload(this.payload.length >= 3); + this.parameter = this.payload.readUInt16BE(0); + this.reportsToFollow = this.payload[2]; + if (this.reportsToFollow > 0) { + // If more reports follow, the info must at least be one byte + validatePayload(this.payload.length >= 4); + } + this.name = this.payload.slice(3).toString("utf8"); + } else { + this.parameter = options.parameter; + this.name = options.name; + this.reportsToFollow = options.reportsToFollow; } - this._name = this.payload.slice(3).toString("utf8"); } + public readonly parameter: number; + public name: string; + public readonly reportsToFollow: number; + public persistValues(applHost: ZWaveApplicationHost): boolean { if (!super.persistValues(applHost)) return false; @@ -2104,28 +2123,23 @@ export class ConfigurationCCNameReport extends ConfigurationCC { return true; } - private _parameter: number; - public get parameter(): number { - return this._parameter; - } - - private _name: string; - public get name(): string { - return this._name; - } + public serialize(): Buffer { + const nameBuffer = Buffer.from(this.name, "utf8"); + this.payload = Buffer.allocUnsafe(3 + nameBuffer.length); + this.payload.writeUInt16BE(this.parameter, 0); + this.payload[2] = this.reportsToFollow; + nameBuffer.copy(this.payload, 3); - private _reportsToFollow: number; - public get reportsToFollow(): number { - return this._reportsToFollow; + return super.serialize(); } public getPartialCCSessionId(): Record | undefined { // Distinguish sessions by the parameter number - return { parameter: this._parameter }; + return { parameter: this.parameter }; } public expectMoreMessages(): boolean { - return this._reportsToFollow > 0; + return this.reportsToFollow > 0; } public mergePartialCCs( @@ -2133,8 +2147,8 @@ export class ConfigurationCCNameReport extends ConfigurationCC { partials: ConfigurationCCNameReport[], ): void { // Concat the name - this._name = [...partials, this] - .map((report) => report._name) + this.name = [...partials, this] + .map((report) => report.name) .reduce((prev, cur) => prev + cur, ""); } @@ -2159,11 +2173,8 @@ export class ConfigurationCCNameGet extends ConfigurationCC { ) { super(host, options); if (gotDeserializationOptions(options)) { - // TODO: Deserialize payload - throw new ZWaveError( - `${this.constructor.name}: deserialization not implemented`, - ZWaveErrorCodes.Deserialization_NotImplemented, - ); + validatePayload(this.payload.length >= 2); + this.parameter = this.payload.readUInt16BE(0); } else { this.parameter = options.parameter; } diff --git a/packages/cc/src/lib/EncapsulatingCommandClass.ts b/packages/cc/src/lib/EncapsulatingCommandClass.ts index 4395e46c3171..2954213866c0 100644 --- a/packages/cc/src/lib/EncapsulatingCommandClass.ts +++ b/packages/cc/src/lib/EncapsulatingCommandClass.ts @@ -49,6 +49,14 @@ export function isEncapsulatingCommandClass( return false; } +export function getInnermostCommandClass(cc: CommandClass): CommandClass { + if (isEncapsulatingCommandClass(cc)) { + return getInnermostCommandClass(cc.encapsulated); + } else { + return cc; + } +} + /** Defines the static side of an encapsulating command class */ export interface MultiEncapsulatingCommandClassStatic { new ( diff --git a/packages/zwave-js/api.md b/packages/zwave-js/api.md index b9e9cc074553..95f04c188cc4 100644 --- a/packages/zwave-js/api.md +++ b/packages/zwave-js/api.md @@ -369,8 +369,9 @@ export class Driver extends TypedEventEmitter implements Z waitForCommand(predicate: (cc: ICommandClass) => boolean, timeout: number): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "zwave-js" does not have an export "waitForCommand" - waitForMessage(predicate: (msg: Message) => boolean, timeout: number): Promise; + waitForMessage(predicate: (msg: Message) => boolean, timeout: number, refreshPredicate?: (msg: Message) => boolean): Promise; } // Warning: (ae-missing-release-tag) "DriverLogContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/packages/zwave-js/src/lib/driver/Driver.ts b/packages/zwave-js/src/lib/driver/Driver.ts index 7d85b4bd6419..710450805497 100644 --- a/packages/zwave-js/src/lib/driver/Driver.ts +++ b/packages/zwave-js/src/lib/driver/Driver.ts @@ -382,6 +382,7 @@ interface AwaitedThing { handler: (thing: T) => void; timeout?: NodeJS.Timeout; predicate: (msg: T) => boolean; + refreshPredicate?: (msg: T) => boolean; } type AwaitedMessageEntry = AwaitedThing; @@ -2894,7 +2895,17 @@ export class Driver } // Assemble partial CCs on the driver level. Only forward complete messages to the send thread machine - if (!this.assemblePartialCCs(msg)) return; + if (!this.assemblePartialCCs(msg)) { + // Check if a message timer needs to be refreshed. + for (const entry of this.awaitedMessages) { + if (entry.refreshPredicate?.(msg)) { + entry.timeout?.refresh(); + // Since this is a partial message there may be no clear 1:1 match. + // Therefore we loop through all awaited messages + } + } + return; + } // Make sure we are allowed to handle this command if (this.shouldDiscardCC(msg.command)) { @@ -4510,16 +4521,19 @@ ${handlers.length} left`, * * **Note:** To wait for a certain CommandClass, better use {@link waitForCommand}. * @param timeout The number of milliseconds to wait. If the timeout elapses, the returned promise will be rejected - * @param predicate A predicate function to test all incoming messages + * @param predicate A predicate function to test all incoming messages. + * @param refreshPredicate A predicate function to test partial messages. If this returns `true` for a message, the timer will be restarted. */ public waitForMessage( predicate: (msg: Message) => boolean, timeout: number, + refreshPredicate?: (msg: Message) => boolean, ): Promise { return new Promise((resolve, reject) => { const promise = createDeferredPromise(); const entry: AwaitedMessageEntry = { predicate, + refreshPredicate, handler: (msg) => promise.resolve(msg), timeout: undefined, }; diff --git a/packages/zwave-js/src/lib/driver/MessageGenerators.ts b/packages/zwave-js/src/lib/driver/MessageGenerators.ts index f008093fcb56..5d2f9de8aca0 100644 --- a/packages/zwave-js/src/lib/driver/MessageGenerators.ts +++ b/packages/zwave-js/src/lib/driver/MessageGenerators.ts @@ -1,5 +1,6 @@ import { CommandClass, + getInnermostCommandClass, isCommandClassContainer, MGRPExtension, MPANExtension, @@ -71,15 +72,39 @@ export type MessageGeneratorImplementation = ( additionalCommandTimeoutMs?: number, ) => AsyncGenerator; +function maybePartialNodeUpdate(sent: Message, received: Message): boolean { + // Some commands are returned in multiple segments, which may take longer than + // the configured timeout. + if (!isCommandClassContainer(sent) || !isCommandClassContainer(received)) { + return false; + } + + if (sent.getNodeId() !== received.getNodeId()) return false; + + if (received.command.ccId === CommandClasses["Transport Service"]) { + // We don't know what's in there. It may be the expected update + return true; + } + + // Let the sent CC test if the received one is a match. + // These predicates don't check if the received CC is complete, we can use them here. + // This also doesn't check for correct encapsulation, but that is good enough to refresh the timer. + const sentCommand = getInnermostCommandClass(sent.command); + const receivedCommand = getInnermostCommandClass(received.command); + return sentCommand.isExpectedCCResponse(receivedCommand); +} + export async function waitForNodeUpdate( driver: Driver, msg: Message, timeoutMs: number, ): Promise { try { - return await driver.waitForMessage((received) => { - return msg.isExpectedNodeUpdate(received); - }, timeoutMs); + return await driver.waitForMessage( + (received) => msg.isExpectedNodeUpdate(received), + timeoutMs, + (received) => maybePartialNodeUpdate(msg, received), + ); } catch (e) { throw new ZWaveError( `Timed out while waiting for a response from the node`, diff --git a/packages/zwave-js/src/lib/test/driver/multiStageResponseNoTimeout.test.ts b/packages/zwave-js/src/lib/test/driver/multiStageResponseNoTimeout.test.ts new file mode 100644 index 000000000000..623765164bb5 --- /dev/null +++ b/packages/zwave-js/src/lib/test/driver/multiStageResponseNoTimeout.test.ts @@ -0,0 +1,216 @@ +import { BasicCCReport } from "@zwave-js/cc/BasicCC"; +import { + ConfigurationCCNameGet, + ConfigurationCCNameReport, +} from "@zwave-js/cc/ConfigurationCC"; +import { + MAX_SEGMENT_SIZE, + TransportServiceCCFirstSegment, + TransportServiceCCSubsequentSegment, +} from "@zwave-js/cc/TransportServiceCC"; +import { CommandClasses } from "@zwave-js/core"; +import { + createMockZWaveRequestFrame, + MockZWaveFrameType, + type MockNodeBehavior, +} from "@zwave-js/testing"; +import { wait } from "alcalzone-shared/async"; +import { integrationTest } from "../integrationTestSuite"; + +integrationTest( + "GET requests don't time out early if the response is split using the 'more to follow' flag", + { + // debug: true, + + nodeCapabilities: { + commandClasses: [ + CommandClasses.Version, + { + ccId: CommandClasses.Configuration, + isSupported: true, + version: 1, + }, + ], + }, + + async customSetup(driver, mockController, mockNode) { + const respondToConfigurationNameGet: MockNodeBehavior = { + async onControllerFrame(controller, self, frame) { + if ( + frame.type === MockZWaveFrameType.Request && + frame.payload instanceof ConfigurationCCNameGet + ) { + await wait(700); + let cc = new ConfigurationCCNameReport(self.host, { + nodeId: controller.host.ownNodeId, + parameter: frame.payload.parameter, + name: "Test para", + reportsToFollow: 1, + }); + await self.sendToController( + createMockZWaveRequestFrame(cc, { + ackRequested: false, + }), + ); + + await wait(700); + + cc = new ConfigurationCCNameReport(self.host, { + nodeId: controller.host.ownNodeId, + parameter: frame.payload.parameter, + name: "meter", + reportsToFollow: 0, + }); + await self.sendToController( + createMockZWaveRequestFrame(cc, { + ackRequested: false, + }), + ); + return true; + } + return false; + }, + }; + mockNode.defineBehavior(respondToConfigurationNameGet); + }, + + async testBody(t, driver, node, mockController, mockNode) { + const name = await node.commandClasses.Configuration.getName(1); + t.is(name, "Test parameter"); + }, + }, +); + +const longName = + "Veeeeeeeeeeeeeeeeeeeeeeeeery loooooooooooooooooong parameter name"; + +integrationTest( + "GET requests don't time out early if the response is split using Transport Service CC", + { + // debug: true, + + nodeCapabilities: { + commandClasses: [ + CommandClasses.Version, + { + ccId: CommandClasses.Configuration, + isSupported: true, + version: 1, + }, + ], + }, + + async customSetup(driver, mockController, mockNode) { + const respondToConfigurationNameGet: MockNodeBehavior = { + async onControllerFrame(controller, self, frame) { + if ( + frame.type === MockZWaveFrameType.Request && + frame.payload instanceof ConfigurationCCNameGet + ) { + const configCC = new ConfigurationCCNameReport( + self.host, + { + nodeId: controller.host.ownNodeId, + parameter: frame.payload.parameter, + name: "Veeeeeeeeeeeeeeeeeeeeeeeeery loooooooooooooooooong parameter name", + reportsToFollow: 0, + }, + ); + const serialized = configCC.serialize(); + const segment1 = serialized.slice(0, MAX_SEGMENT_SIZE); + const segment2 = serialized.slice(MAX_SEGMENT_SIZE); + + const sessionId = 7; + + const tsFS = new TransportServiceCCFirstSegment( + self.host, + { + nodeId: controller.host.ownNodeId, + sessionId, + datagramSize: serialized.length, + partialDatagram: segment1, + }, + ); + const tsSS = new TransportServiceCCSubsequentSegment( + self.host, + { + nodeId: controller.host.ownNodeId, + sessionId, + datagramSize: serialized.length, + datagramOffset: segment1.length, + partialDatagram: segment2, + }, + ); + + await wait(700); + await self.sendToController( + createMockZWaveRequestFrame(tsFS, { + ackRequested: false, + }), + ); + + await wait(700); + await self.sendToController( + createMockZWaveRequestFrame(tsSS, { + ackRequested: false, + }), + ); + return true; + } + return false; + }, + }; + mockNode.defineBehavior(respondToConfigurationNameGet); + }, + + async testBody(t, driver, node, mockController, mockNode) { + const name = await node.commandClasses.Configuration.getName(1); + t.is(name, longName); + }, + }, +); + +integrationTest("GET requests DO time out if there's no matching response", { + // debug: true, + + nodeCapabilities: { + commandClasses: [ + CommandClasses.Version, + { + ccId: CommandClasses.Configuration, + isSupported: true, + version: 1, + }, + ], + }, + + async customSetup(driver, mockController, mockNode) { + const respondToConfigurationNameGet: MockNodeBehavior = { + async onControllerFrame(controller, self, frame) { + if ( + frame.type === MockZWaveFrameType.Request && + frame.payload instanceof ConfigurationCCNameGet + ) { + // This is not the response you're looking for + const cc = new BasicCCReport(self.host, { + nodeId: controller.host.ownNodeId, + currentValue: 1, + }); + await self.sendToController( + createMockZWaveRequestFrame(cc, { + ackRequested: false, + }), + ); + return true; + } + return false; + }, + }; + mockNode.defineBehavior(respondToConfigurationNameGet); + }, + + async testBody(t, driver, node, mockController, mockNode) { + const name = await node.commandClasses.Configuration.getName(1); + t.is(name, undefined); + }, +}); diff --git a/packages/zwave-js/src/lib/test/integrationTestSuite.ts b/packages/zwave-js/src/lib/test/integrationTestSuite.ts index 1a21d8358168..21751cccaaa8 100644 --- a/packages/zwave-js/src/lib/test/integrationTestSuite.ts +++ b/packages/zwave-js/src/lib/test/integrationTestSuite.ts @@ -19,6 +19,9 @@ import { import type { ZWaveOptions } from "../driver/ZWaveOptions"; import type { ZWaveNode } from "../node/Node"; +// Integration tests need to run in serial, or they might block the serial port on CI +const testSerial = test.serial.bind(test); + function prepareDriver( cacheDir: string = path.join(__dirname, "cache"), logToFile: boolean = false, @@ -198,11 +201,11 @@ function suite( } (modifier === "only" - ? test.only + ? testSerial.only : modifier === "skip" - ? test.skip - : test - ).bind(test)(name, async (t) => { + ? testSerial.skip + : testSerial + ).bind(testSerial)(name, async (t) => { t.timeout(30000); t.teardown(async () => { // Give everything a chance to settle before destroying the driver.