diff --git a/packages/zwave-js/src/lib/driver/Driver.ts b/packages/zwave-js/src/lib/driver/Driver.ts index 82b2bb77b12d..ace5128f2ff0 100644 --- a/packages/zwave-js/src/lib/driver/Driver.ts +++ b/packages/zwave-js/src/lib/driver/Driver.ts @@ -2630,6 +2630,10 @@ export class Driver extends TypedEventEmitter } catch { // ignore } + + // No need to keep the node awake longer than necessary + node.keepAwake = false; + this.debounceSendNodeToSleep(node); } } @@ -4144,7 +4148,7 @@ export class Driver extends TypedEventEmitter "Attempting to recover controller again...", "warn", ); - void this.softReset().catch(() => { + void this.softResetInternal(true).catch(() => { this.driverLog.print( "Automatic controller recovery failed. Returning to normal operation and hoping for the best.", "warn", @@ -4173,7 +4177,7 @@ export class Driver extends TypedEventEmitter ); // Execute the soft-reset asynchronously - void this.softReset().then(() => { + void this.softResetInternal(true).then(() => { // The controller responded. It is no longer unresponsive. // Re-queue the transaction, so it can get handled next. diff --git a/packages/zwave-js/src/lib/driver/Task.ts b/packages/zwave-js/src/lib/driver/Task.ts index 12492b8cf2b4..5a95ccb469ef 100644 --- a/packages/zwave-js/src/lib/driver/Task.ts +++ b/packages/zwave-js/src/lib/driver/Task.ts @@ -173,6 +173,11 @@ export type TaskTag = // Rebuild routes for a single node id: "rebuild-node-routes"; nodeId: number; + } + | { + // Perform an OTA firmware update for a node + id: "firmware-update-ota"; + nodeId: number; }; export class TaskScheduler { diff --git a/packages/zwave-js/src/lib/node/Node.ts b/packages/zwave-js/src/lib/node/Node.ts index 59b04a3e0363..c623b2dda576 100644 --- a/packages/zwave-js/src/lib/node/Node.ts +++ b/packages/zwave-js/src/lib/node/Node.ts @@ -10,6 +10,7 @@ import { DoorLockMode, EntryControlDataTypes, type FirmwareUpdateCapabilities, + type FirmwareUpdateInitResult, type FirmwareUpdateMetaData, type FirmwareUpdateOptions, type FirmwareUpdateProgress, @@ -257,6 +258,7 @@ import { type Driver, libVersion } from "../driver/Driver"; import { cacheKeys } from "../driver/NetworkCache"; import { type Extended, interpretEx } from "../driver/StateMachineShared"; import type { StatisticsEventCallbacksWithSelf } from "../driver/Statistics"; +import { type TaskBuilder, TaskPriority } from "../driver/Task"; import type { Transaction } from "../driver/Transaction"; import { type ApplicationUpdateRequest, @@ -321,6 +323,10 @@ interface AbortFirmwareUpdateContext { abortPromise: DeferredPromise; } +type PartialFirmwareUpdateResult = + & Pick + & { success: boolean }; + const MAX_ASSOCIATIONS = 1; export interface ZWaveNode extends @@ -5542,29 +5548,6 @@ protocol version: ${this.protocolVersion}`; } } - private _firmwareUpdateInProgress: boolean = false; - /** - * Returns whether a firmware update is in progress for this node. - */ - public isFirmwareUpdateInProgress(): boolean { - if (this.isControllerNode) { - return this.driver.controller.isFirmwareUpdateInProgress(); - } else { - return this._firmwareUpdateInProgress; - } - } - - // Stores the CRC of the previously transferred firmware image. - // Allows detecting whether resuming is supported and where to continue in a multi-file transfer. - private _previousFirmwareCRC: number | undefined; - - private _abortFirmwareUpdate: (() => Promise) | undefined; - - /** Is used to remember fragment requests that came in before they were able to be handled */ - private _firmwareUpdatePrematureRequest: - | FirmwareUpdateMetaDataCCGet - | undefined; - /** * Retrieves the firmware update capabilities of a node to decide which options to offer a user prior to the update. * This method uses cached information from the most recent interview. @@ -5641,6 +5624,24 @@ protocol version: ${this.protocolVersion}`; }; } + private _abortFirmwareUpdate: (() => Promise) | undefined; + /** + * Aborts an active firmware update process + */ + public async abortFirmwareUpdate(): Promise { + if (!this._abortFirmwareUpdate) return; + await this._abortFirmwareUpdate(); + } + + // Stores the CRC of the previously transferred firmware image. + // Allows detecting whether resuming is supported and where to continue in a multi-file transfer. + private _previousFirmwareCRC: number | undefined; + + /** Is used to remember fragment requests that came in before they were able to be handled */ + private _firmwareUpdatePrematureRequest: + | FirmwareUpdateMetaDataCCGet + | undefined; + /** * Performs an OTA firmware upgrade of one or more chips on this node. * @@ -5683,317 +5684,382 @@ protocol version: ${this.protocolVersion}`; } // Don't start the process twice - if (this.isFirmwareUpdateInProgress()) { + if (this.driver.controller.isFirmwareUpdateInProgress()) { throw new ZWaveError( - `Failed to start the update: A firmware upgrade is already in progress!`, + `Failed to start the update: An OTW upgrade of the controller is in progress!`, ZWaveErrorCodes.FirmwareUpdateCC_Busy, ); } - // Don't let two firmware updates happen in parallel - if (this.driver.controller.isAnyOTAFirmwareUpdateInProgress()) { + // Don't allow starting two firmware updates for the same node + const task = this.getUpdateFirmwareTask(updates, options); + if (task instanceof Promise) { throw new ZWaveError( - `Failed to start the update: A firmware update is already in progress on this network!`, - ZWaveErrorCodes.FirmwareUpdateCC_NetworkBusy, + `Failed to start the update: A firmware update is already in progress for this node!`, + ZWaveErrorCodes.FirmwareUpdateCC_Busy, ); } - this._firmwareUpdateInProgress = true; - // Support aborting the update - const abortContext = { - abort: false, - tooLateToAbort: false, - abortPromise: createDeferredPromise(), - }; + // Queue the task + return this.driver.scheduler.queueTask(task); + } - this._abortFirmwareUpdate = async () => { - if (abortContext.tooLateToAbort) { - throw new ZWaveError( - `The firmware update was transmitted completely, cannot abort anymore.`, - ZWaveErrorCodes.FirmwareUpdateCC_FailedToAbort, - ); - } + /** + * Returns whether a firmware update is in progress for this node. + */ + public isFirmwareUpdateInProgress(): boolean { + return !!this.driver.scheduler.findTask( + nodeUtils.isFirmwareUpdateOTATask, + ); + } - this.driver.controllerLog.logNode(this.id, { - message: `Aborting firmware update...`, - direction: "outbound", - }); + private getUpdateFirmwareTask( + updates: Firmware[], + options: FirmwareUpdateOptions = {}, + ): Promise | TaskBuilder { + const self = this; + + // This task should only run once at a time + const existingTask = this.driver.scheduler.findTask< + FirmwareUpdateResult + >((t) => + t.tag?.id === "firmware-update-ota" + && t.tag.nodeId === self.id + ); + if (existingTask) return existingTask; - // Trigger the abort - abortContext.abort = true; - const aborted = await abortContext.abortPromise; - if (!aborted) { - throw new ZWaveError( - `The node did not acknowledge the aborted update`, - ZWaveErrorCodes.FirmwareUpdateCC_FailedToAbort, - ); - } - this.driver.controllerLog.logNode(this.id, { - message: `Firmware update aborted`, - direction: "inbound", - }); - }; + let keepAwake: boolean; - // If the node isn't supposed to be kept awake yet, do it - this.keepAwake = true; + return { + // Firmware updates cause a lot of traffic. Execute them in the background. + priority: TaskPriority.Lower, + tag: { id: "firmware-update-ota", nodeId: self.id }, + task: async function* firmwareUpdateTask() { + // Keep battery powered nodes awake during the process + keepAwake = self.keepAwake; + self.keepAwake = true; + + // Support aborting the update + const abortContext = { + abort: false, + tooLateToAbort: false, + abortPromise: createDeferredPromise(), + }; - // Reset persisted state after the update - const restore = (keepAwake: boolean) => { - this.keepAwake = keepAwake; - this._firmwareUpdateInProgress = false; - this._abortFirmwareUpdate = undefined; - this._firmwareUpdatePrematureRequest = undefined; - }; + self._abortFirmwareUpdate = async () => { + if (abortContext.tooLateToAbort) { + throw new ZWaveError( + `The firmware update was transmitted completely, cannot abort anymore.`, + ZWaveErrorCodes.FirmwareUpdateCC_FailedToAbort, + ); + } - // Prepare the firmware update - let fragmentSizeSecure: number; - let fragmentSizeNonSecure: number; - let meta: FirmwareUpdateMetaData; - try { - const prepareResult = await this.prepareFirmwareUpdateInternal( - updates.map((u) => u.firmwareTarget ?? 0), - abortContext, - ); + self.driver.controllerLog.logNode(self.id, { + message: `Aborting firmware update...`, + direction: "outbound", + }); - // Handle early aborts - if (abortContext.abort) { - const result: FirmwareUpdateResult = { - success: false, - status: FirmwareUpdateStatus.Error_TransmissionFailed, - reInterview: false, + // Trigger the abort + abortContext.abort = true; + const aborted = await abortContext.abortPromise; + if (!aborted) { + throw new ZWaveError( + `The node did not acknowledge the aborted update`, + ZWaveErrorCodes.FirmwareUpdateCC_FailedToAbort, + ); + } + self.driver.controllerLog.logNode(self.id, { + message: `Firmware update aborted`, + direction: "inbound", + }); }; - this.emit("firmware update finished", this, result); - restore(false); - return result; - } - - // If the firmware update was not aborted, prepareResult is definitely defined - ({ fragmentSizeSecure, fragmentSizeNonSecure, ...meta } = - prepareResult!); - } catch { - restore(false); - // Not sure what the error is, but we'll label it "transmission failed" - const result: FirmwareUpdateResult = { - success: false, - status: FirmwareUpdateStatus.Error_TransmissionFailed, - reInterview: false, - }; - return result; - } + // Prepare the firmware update + let fragmentSizeSecure: number; + let fragmentSizeNonSecure: number; + let meta: FirmwareUpdateMetaData; + try { + const prepareResult = await self + .prepareFirmwareUpdateInternal( + updates.map((u) => u.firmwareTarget ?? 0), + abortContext, + ); - // The resume and non-secure transfer features may not be supported by the node - // If not, disable them, even though the application requested them - if (!meta.supportsResuming) options.resume = false; + // Handle early aborts + if (abortContext.abort) { + const result: FirmwareUpdateResult = { + success: false, + status: FirmwareUpdateStatus + .Error_TransmissionFailed, + reInterview: false, + }; + self.emit( + "firmware update finished", + self, + result, + ); + return result; + } - const securityClass = this.getHighestSecurityClass(); - const isSecure = securityClass === SecurityClass.S0_Legacy - || securityClassIsS2(securityClass); - if (!isSecure) { - // The nonSecureTransfer option is only relevant for secure devices - options.nonSecureTransfer = false; - } else if (!meta.supportsNonSecureTransfer) { - options.nonSecureTransfer = false; - } - - // Throttle the progress emitter so applications can handle the load of events - const notifyProgress = throttle( - (progress) => this.emit("firmware update progress", this, progress), - 250, - true, - ); + // If the firmware update was not aborted, prepareResult is definitely defined + ({ + fragmentSizeSecure, + fragmentSizeNonSecure, + ...meta + } = prepareResult!); + } catch { + // Not sure what the error is, but we'll label it "transmission failed" + const result: FirmwareUpdateResult = { + success: false, + status: FirmwareUpdateStatus.Error_TransmissionFailed, + reInterview: false, + }; - // If resuming is supported and desired, try to figure out with which file to continue - const updatesWithChecksum = updates.map((u) => ({ - ...u, - checksum: CRC16_CCITT(u.data), - })); - let skipFinishedFiles = -1; - let shouldResume = options.resume - && this._previousFirmwareCRC != undefined; - if (shouldResume) { - skipFinishedFiles = updatesWithChecksum.findIndex( - (u) => u.checksum === this._previousFirmwareCRC, - ); - if (skipFinishedFiles === -1) shouldResume = false; - } + return result; + } - // Perform all firmware updates in sequence - let updateResult!: Awaited< - ReturnType - >; - let conservativeWaitTime: number; + yield; // Give the task scheduler time to do something else - const totalBytes: number = updatesWithChecksum.reduce( - (total, update) => total + update.data.length, - 0, - ); - let sentBytesOfPreviousFiles = 0; + // The resume and non-secure transfer features may not be supported by the node + // If not, disable them, even though the application requested them + if (!meta.supportsResuming) options.resume = false; - for (let i = 0; i < updatesWithChecksum.length; i++) { - const { firmwareTarget: target = 0, data, checksum } = - updatesWithChecksum[i]; + const securityClass = self.getHighestSecurityClass(); + const isSecure = securityClass === SecurityClass.S0_Legacy + || securityClassIsS2(securityClass); + if (!isSecure) { + // The nonSecureTransfer option is only relevant for secure devices + options.nonSecureTransfer = false; + } else if (!meta.supportsNonSecureTransfer) { + options.nonSecureTransfer = false; + } - if (i < skipFinishedFiles) { - // If we are resuming, skip this file since it was already done before - this.driver.controllerLog.logNode( - this.id, - `Skipping already completed firmware update (part ${ - i + 1 - } / ${updatesWithChecksum.length})...`, + // Throttle the progress emitter so applications can handle the load of events + const notifyProgress = throttle( + (progress) => + self.emit( + "firmware update progress", + self, + progress, + ), + 250, + true, ); - sentBytesOfPreviousFiles += data.length; - continue; - } - this.driver.controllerLog.logNode( - this.id, - `Updating firmware (part ${ - i + 1 - } / ${updatesWithChecksum.length})...`, - ); + // If resuming is supported and desired, try to figure out with which file to continue + const updatesWithChecksum = updates.map((u) => ({ + ...u, + checksum: CRC16_CCITT(u.data), + })); + let skipFinishedFiles = -1; + let shouldResume = options.resume + && self._previousFirmwareCRC != undefined; + if (shouldResume) { + skipFinishedFiles = updatesWithChecksum.findIndex( + (u) => u.checksum === self._previousFirmwareCRC, + ); + if (skipFinishedFiles === -1) shouldResume = false; + } + + // Perform all firmware updates in sequence + let updateResult!: PartialFirmwareUpdateResult; + let conservativeWaitTime: number; - // For determining the initial fragment size, assume the node respects our choice. - // If the node is not secure, these two values are identical anyways. - let fragmentSize = options.nonSecureTransfer - ? fragmentSizeNonSecure - : fragmentSizeSecure; - - // Tell the node to start requesting fragments - const { resume, nonSecureTransfer } = await this - .beginFirmwareUpdateInternal( - data, - target, - meta, - fragmentSize, - checksum, - shouldResume, - options.nonSecureTransfer, + const totalBytes: number = updatesWithChecksum.reduce( + (total, update) => total + update.data.length, + 0, ); + let sentBytesOfPreviousFiles = 0; + + for (let i = 0; i < updatesWithChecksum.length; i++) { + const { firmwareTarget: target = 0, data, checksum } = + updatesWithChecksum[i]; + + if (i < skipFinishedFiles) { + // If we are resuming, skip this file since it was already done before + self.driver.controllerLog.logNode( + self.id, + `Skipping already completed firmware update (part ${ + i + 1 + } / ${updatesWithChecksum.length})...`, + ); + sentBytesOfPreviousFiles += data.length; + continue; + } - // If the node did not accept non-secure transfer, revisit our choice of fragment size - if (options.nonSecureTransfer && !nonSecureTransfer) { - fragmentSize = fragmentSizeSecure; - } + self.driver.controllerLog.logNode( + self.id, + `Updating firmware (part ${ + i + 1 + } / ${updatesWithChecksum.length})...`, + ); - // Remember the checksum, so we can resume if necessary - this._previousFirmwareCRC = checksum; + // For determining the initial fragment size, assume the node respects our choice. + // If the node is not secure, these two values are identical anyways. + let fragmentSize = options.nonSecureTransfer + ? fragmentSizeNonSecure + : fragmentSizeSecure; + + // Tell the node to start requesting fragments + const { resume, nonSecureTransfer } = yield* self + .beginFirmwareUpdateInternal( + data, + target, + meta, + fragmentSize, + checksum, + shouldResume, + options.nonSecureTransfer, + ); - if (shouldResume) { - this.driver.controllerLog.logNode( - this.id, - `Node ${ - resume ? "accepted" : "did not accept" - } resuming the update...`, - ); - } - if (nonSecureTransfer) { - this.driver.controllerLog.logNode( - this.id, - `Firmware will be transferred without encryption...`, - ); - } + // If the node did not accept non-secure transfer, revisit our choice of fragment size + if (options.nonSecureTransfer && !nonSecureTransfer) { + fragmentSize = fragmentSizeSecure; + } - // And handle them - updateResult = await this.doFirmwareUpdateInternal( - data, - fragmentSize, - nonSecureTransfer, - abortContext, - (fragment, total) => { - const progress: FirmwareUpdateProgress = { - currentFile: i + 1, - totalFiles: updatesWithChecksum.length, - sentFragments: fragment, - totalFragments: total, - progress: roundTo( - ( - (sentBytesOfPreviousFiles - + Math.min( - fragment * fragmentSize, - data.length, - )) - / totalBytes - ) * 100, - 2, - ), - }; - notifyProgress(progress); + // Remember the checksum, so we can resume if necessary + self._previousFirmwareCRC = checksum; - // When this file is done, add the fragments to the total, so we can compute the total progress correctly - if (fragment === total) { - sentBytesOfPreviousFiles += data.length; + if (shouldResume) { + self.driver.controllerLog.logNode( + self.id, + `Node ${ + resume ? "accepted" : "did not accept" + } resuming the update...`, + ); + } + if (nonSecureTransfer) { + self.driver.controllerLog.logNode( + self.id, + `Firmware will be transferred without encryption...`, + ); } - }, - ); - // If we wait, wait a bit longer than the device told us, so it is actually ready to use - conservativeWaitTime = this.driver - .getConservativeWaitTimeAfterFirmwareUpdate( - updateResult.waitTime, - ); + yield; // Give the task scheduler time to do something else - if (!updateResult.success) { - this.driver.controllerLog.logNode(this.id, { - message: `Firmware update (part ${ - i + 1 - } / ${updatesWithChecksum.length}) failed with status ${ - getEnumMemberName( - FirmwareUpdateStatus, - updateResult.status, - ) - }`, - direction: "inbound", - }); + // Listen for firmware update fragment requests and handle them + updateResult = yield* self.doFirmwareUpdateInternal( + data, + fragmentSize, + nonSecureTransfer, + abortContext, + (fragment, total) => { + const progress: FirmwareUpdateProgress = { + currentFile: i + 1, + totalFiles: updatesWithChecksum.length, + sentFragments: fragment, + totalFragments: total, + progress: roundTo( + ( + (sentBytesOfPreviousFiles + + Math.min( + fragment * fragmentSize, + data.length, + )) + / totalBytes + ) * 100, + 2, + ), + }; + notifyProgress(progress); + + // When this file is done, add the fragments to the total, so we can compute the total progress correctly + if (fragment === total) { + sentBytesOfPreviousFiles += data.length; + } + }, + ); - const result: FirmwareUpdateResult = { - ...updateResult, - waitTime: undefined, - reInterview: false, - }; - this.emit("firmware update finished", this, result); - restore(false); - return result; - } else if (i < updatesWithChecksum.length - 1) { - // Update succeeded, but we're not done yet + // If we wait, wait a bit longer than the device told us, so it is actually ready to use + conservativeWaitTime = self.driver + .getConservativeWaitTimeAfterFirmwareUpdate( + updateResult.waitTime, + ); - this.driver.controllerLog.logNode(this.id, { - message: `Firmware update (part ${ - i + 1 - } / ${updatesWithChecksum.length}) succeeded with status ${ - getEnumMemberName( - FirmwareUpdateStatus, - updateResult.status, - ) - }`, - direction: "inbound", - }); + if (!updateResult.success) { + self.driver.controllerLog.logNode(self.id, { + message: `Firmware update (part ${ + i + 1 + } / ${updatesWithChecksum.length}) failed with status ${ + getEnumMemberName( + FirmwareUpdateStatus, + updateResult.status, + ) + }`, + direction: "inbound", + }); - this.driver.controllerLog.logNode( - this.id, - `Continuing with next part in ${conservativeWaitTime} seconds...`, - ); + const result: FirmwareUpdateResult = { + ...updateResult, + waitTime: undefined, + reInterview: false, + }; + self.emit( + "firmware update finished", + self, + result, + ); - // If we've resumed the previous file, there's no need to resume the next one too - shouldResume = false; + return result; + } else if (i < updatesWithChecksum.length - 1) { + // Update succeeded, but we're not done yet + + self.driver.controllerLog.logNode(self.id, { + message: `Firmware update (part ${ + i + 1 + } / ${updatesWithChecksum.length}) succeeded with status ${ + getEnumMemberName( + FirmwareUpdateStatus, + updateResult.status, + ) + }`, + direction: "inbound", + }); - await wait(conservativeWaitTime * 1000, true); - } - } + self.driver.controllerLog.logNode( + self.id, + `Continuing with next part in ${conservativeWaitTime} seconds...`, + ); - // We're done. No need to resume this update - this._previousFirmwareCRC = undefined; + // If we've resumed the previous file, there's no need to resume the next one too + shouldResume = false; - const result: FirmwareUpdateResult = { - ...updateResult, - waitTime: conservativeWaitTime!, - reInterview: true, - }; + yield () => wait(conservativeWaitTime * 1000, true); + } + } - this.emit("firmware update finished", this, result); + // We're done. No need to resume this update + self._previousFirmwareCRC = undefined; - restore(true); - return result; + const result: FirmwareUpdateResult = { + ...updateResult, + waitTime: conservativeWaitTime!, + reInterview: true, + }; + + // After a successful firmware update, we want to interview sleeping nodes immediately, + // so don't send them to sleep when they wake up + keepAwake = true; + + self.emit("firmware update finished", self, result); + + return result; + }, + cleanup() { + self._abortFirmwareUpdate = undefined; + self._firmwareUpdatePrematureRequest = undefined; + + // Make sure that the keepAwake flag gets reset at the end + self.keepAwake = keepAwake; + if (!keepAwake) { + setImmediate(() => { + self.driver.debounceSendNodeToSleep(self); + }); + } + + return Promise.resolve(); + }, + }; } /** Prepares the firmware update of a single target by collecting the necessary information */ @@ -6051,9 +6117,10 @@ protocol version: ${this.protocolVersion}`; const fcc = new FirmwareUpdateMetaDataCC(this.driver, { nodeId: this.id, }); - const maxGrossPayloadSizeSecure = this.driver.computeNetCCPayloadSize( - fcc, - ); + const maxGrossPayloadSizeSecure = this.driver + .computeNetCCPayloadSize( + fcc, + ); const maxGrossPayloadSizeNonSecure = this.driver .computeNetCCPayloadSize(fcc, true); @@ -6086,8 +6153,43 @@ protocol version: ${this.protocolVersion}`; } } + protected async handleUnexpectedFirmwareUpdateGet( + command: FirmwareUpdateMetaDataCCGet, + ): Promise { + // This method will only be called under two circumstances: + // 1. The node is currently busy responding to a firmware update request -> remember the request + if (this.isFirmwareUpdateInProgress()) { + this._firmwareUpdatePrematureRequest = command; + return; + } + + // 2. No firmware update is in progress -> abort + this.driver.controllerLog.logNode(this.id, { + message: + `Received Firmware Update Get, but no firmware update is in progress. Forcing the node to abort...`, + direction: "inbound", + }); + + // Since no update is in progress, we need to determine the fragment size again + const fcc = new FirmwareUpdateMetaDataCC(this.driver, { + nodeId: this.id, + }); + const fragmentSize = this.driver.computeNetCCPayloadSize(fcc) + - 2 // report number + - (fcc.version >= 2 ? 2 : 0); // checksum + const fragment = randomBytes(fragmentSize); + try { + await this.sendCorruptedFirmwareUpdateReport( + command.reportNumber, + fragment, + ); + } catch { + // ignore + } + } + /** Kicks off a firmware update of a single target. Returns whether the node accepted resuming and non-secure transfer */ - private async beginFirmwareUpdateInternal( + private *beginFirmwareUpdateInternal( data: Buffer, target: number, meta: FirmwareUpdateMetaData, @@ -6095,10 +6197,7 @@ protocol version: ${this.protocolVersion}`; checksum: number, resume: boolean | undefined, nonSecureTransfer: boolean | undefined, - ): Promise<{ - resume: boolean; - nonSecureTransfer: boolean; - }> { + ) { const api = this.commandClasses["Firmware Update Meta Data"]; // ================================ @@ -6109,19 +6208,21 @@ protocol version: ${this.protocolVersion}`; direction: "outbound", }); - // Request the node to start the upgrade - // TODO: Should manufacturer id and firmware id be provided externally? - const result = await api.requestUpdate({ - manufacturerId: meta.manufacturerId, - firmwareId: target == 0 - ? meta.firmwareId - : meta.additionalFirmwareIDs[target - 1], - firmwareTarget: target, - fragmentSize, - checksum, - resume, - nonSecureTransfer, - }); + // Request the node to start the upgrade. Pause the task until this is done, + // since the call can block for a long time + const result: FirmwareUpdateInitResult = yield () => + api.requestUpdate({ + // TODO: Should manufacturer id and firmware id be provided externally? + manufacturerId: meta.manufacturerId, + firmwareId: target == 0 + ? meta.firmwareId + : meta.additionalFirmwareIDs[target - 1], + firmwareTarget: target, + fragmentSize, + checksum, + resume, + nonSecureTransfer, + }); switch (result.status) { case FirmwareUpdateRequestStatus.Error_AuthenticationExpected: throw new ZWaveError( @@ -6133,7 +6234,8 @@ protocol version: ${this.protocolVersion}`; `Failed to start the update: The battery level is too low!`, ZWaveErrorCodes.FirmwareUpdateCC_FailedToStart, ); - case FirmwareUpdateRequestStatus.Error_FirmwareUpgradeInProgress: + case FirmwareUpdateRequestStatus + .Error_FirmwareUpgradeInProgress: throw new ZWaveError( `Failed to start the update: A firmware upgrade is already in progress!`, ZWaveErrorCodes.FirmwareUpdateCC_Busy, @@ -6171,17 +6273,75 @@ protocol version: ${this.protocolVersion}`; }; } - /** Performs the firmware update of a single target */ - private async doFirmwareUpdateInternal( + protected async handleFirmwareUpdateMetaDataGet( + command: FirmwareUpdateMetaDataCCMetaDataGet, + ): Promise { + const endpoint = this.getEndpoint(command.endpointIndex) + ?? this; + + // We are being queried, so the device may actually not support the CC, just control it. + // Using the commandClasses property would throw in that case + const api = endpoint + .createAPI(CommandClasses["Firmware Update Meta Data"], false) + .withOptions({ + // Answer with the same encapsulation as asked, but omit + // Supervision as it shouldn't be used for Get-Report flows + encapsulationFlags: command.encapsulationFlags + & ~EncapsulationFlags.Supervision, + }); + + // We do not support the firmware to be upgraded. + await api.reportMetaData({ + manufacturerId: this.driver.options.vendor?.manufacturerId + ?? 0xffff, + firmwareUpgradable: false, + hardwareVersion: this.driver.options.vendor?.hardwareVersion + ?? 0, + }); + } + + private async sendCorruptedFirmwareUpdateReport( + reportNum: number, + fragment: Buffer, + nonSecureTransfer: boolean = false, + ): Promise { + try { + await this.commandClasses["Firmware Update Meta Data"] + .withOptions({ + // Only encapsulate if the transfer is secure + autoEncapsulate: !nonSecureTransfer, + }) + .sendFirmwareFragment(reportNum, true, fragment); + } catch { + // ignore + } + } + + private hasPendingFirmwareUpdateFragment( + fragmentNumber: number, + ): boolean { + // Avoid queuing duplicate fragments + const isCurrentFirmwareFragment = (t: Transaction) => + t.message.getNodeId() === this.id + && isCommandClassContainer(t.message) + && t.message.command instanceof FirmwareUpdateMetaDataCCReport + && t.message.command.reportNumber === fragmentNumber; + + return this.driver.hasPendingTransactions( + isCurrentFirmwareFragment, + ); + } + + private async *doFirmwareUpdateInternal( data: Buffer, fragmentSize: number, nonSecureTransfer: boolean, abortContext: AbortFirmwareUpdateContext, onProgress: (fragment: number, total: number) => void, - ): Promise< - Pick & { - success: boolean; - } + ): AsyncGenerator< + any, + PartialFirmwareUpdateResult, + any > { const numFragments = Math.ceil(data.length / fragmentSize); @@ -6192,42 +6352,45 @@ protocol version: ${this.protocolVersion}`; // STEP 4: // Respond to fragment requests from the node update: while (true) { + yield; // Give the task scheduler time to do something else + // During ongoing firmware updates, it can happen that the next request is received before the callback for the previous response // is back. In that case we can immediately handle the premature request. Otherwise wait for the next request. - const fragmentRequest = this._firmwareUpdatePrematureRequest - ?? (await this.driver - .waitForCommand( - (cc) => - cc.nodeId === this.id - && cc instanceof FirmwareUpdateMetaDataCCGet, - // Wait up to 2 minutes for each fragment request. - // Some users try to update devices with unstable connections, where 30s can be too short. - timespan.minutes(2), - ) - .catch(() => undefined)); - this._firmwareUpdatePrematureRequest = undefined; - - if (!fragmentRequest) { - // In some cases it can happen that the device stops requesting update frames - // We need to timeout the update in this case so it can be restarted - - this.driver.controllerLog.logNode(this.id, { - message: `Firmware update timed out`, - direction: "none", - level: "warn", - }); + let fragmentRequest: FirmwareUpdateMetaDataCCGet; + if (this._firmwareUpdatePrematureRequest) { + fragmentRequest = this._firmwareUpdatePrematureRequest; + this._firmwareUpdatePrematureRequest = undefined; + } else { + try { + fragmentRequest = yield () => + this.driver + .waitForCommand( + (cc) => + cc.nodeId === this.id + && cc + instanceof FirmwareUpdateMetaDataCCGet, + // Wait up to 2 minutes for each fragment request. + // Some users try to update devices with unstable connections, where 30s can be too short. + timespan.minutes(2), + ); + } catch { + // In some cases it can happen that the device stops requesting update frames + // We need to timeout the update in this case so it can be restarted + this.driver.controllerLog.logNode(this.id, { + message: `Firmware update timed out`, + direction: "none", + level: "warn", + }); - return { - success: false, - status: FirmwareUpdateStatus.Error_Timeout, - }; + return { + success: false, + status: FirmwareUpdateStatus.Error_Timeout, + }; + } } + // When a node requests a firmware update fragment, it must be awake - try { - this.markAsAwake(); - } catch { - /* ignore */ - } + this.markAsAwake(); if (fragmentRequest.reportNumber > numFragments) { this.driver.controllerLog.logNode(this.id, { @@ -6247,9 +6410,13 @@ protocol version: ${this.protocolVersion}`; // Actually send the requested frames request: for ( let num = fragmentRequest.reportNumber; - num < fragmentRequest.reportNumber + fragmentRequest.numReports; + num + < fragmentRequest.reportNumber + + fragmentRequest.numReports; num++ ) { + yield; // Give the task scheduler time to do something else + // Check if the node requested more fragments than are left if (num > numFragments) { break; @@ -6285,7 +6452,8 @@ protocol version: ${this.protocolVersion}`; const isLast = num === numFragments; try { - await this.commandClasses["Firmware Update Meta Data"] + await this + .commandClasses["Firmware Update Meta Data"] .withOptions({ // Only encapsulate if the transfer is secure autoEncapsulate: !nonSecureTransfer, @@ -6313,25 +6481,35 @@ protocol version: ${this.protocolVersion}`; } } + yield; // Give the task scheduler time to do something else + // ================================ // STEP 5: // Finalize the update process - const statusReport = await this.driver - .waitForCommand( - (cc) => - cc.nodeId === this.id - && cc instanceof FirmwareUpdateMetaDataCCStatusReport, - // Wait up to 5 minutes. It should never take that long, but the specs - // don't say anything specific - 5 * 60000, - ) - .catch(() => undefined); + const statusReport: + | FirmwareUpdateMetaDataCCStatusReport + | undefined = yield () => + this.driver + .waitForCommand( + (cc) => + cc.nodeId === this.id + && cc + instanceof FirmwareUpdateMetaDataCCStatusReport, + // Wait up to 5 minutes. It should never take that long, but the specs + // don't say anything specific + 5 * 60000, + ) + .catch(() => undefined); if (abortContext.abort) { abortContext.abortPromise.resolve( + // The error should be Error_TransmissionFailed, but some devices + // use the Error_Checksum error code instead statusReport?.status - === FirmwareUpdateStatus.Error_TransmissionFailed, + === FirmwareUpdateStatus.Error_TransmissionFailed + || statusReport?.status + === FirmwareUpdateStatus.Error_Checksum, ); } @@ -6361,102 +6539,6 @@ protocol version: ${this.protocolVersion}`; }; } - /** - * Aborts an active firmware update process - */ - public async abortFirmwareUpdate(): Promise { - if (!this._abortFirmwareUpdate) return; - await this._abortFirmwareUpdate(); - } - - private async sendCorruptedFirmwareUpdateReport( - reportNum: number, - fragment: Buffer, - nonSecureTransfer: boolean = false, - ): Promise { - try { - await this.commandClasses["Firmware Update Meta Data"] - .withOptions({ - // Only encapsulate if the transfer is secure - autoEncapsulate: !nonSecureTransfer, - }) - .sendFirmwareFragment(reportNum, true, fragment); - } catch { - // ignore - } - } - - private hasPendingFirmwareUpdateFragment(fragmentNumber: number): boolean { - // Avoid queuing duplicate fragments - const isCurrentFirmwareFragment = (t: Transaction) => - t.message.getNodeId() === this.id - && isCommandClassContainer(t.message) - && t.message.command instanceof FirmwareUpdateMetaDataCCReport - && t.message.command.reportNumber === fragmentNumber; - - return this.driver.hasPendingTransactions(isCurrentFirmwareFragment); - } - - private async handleUnexpectedFirmwareUpdateGet( - command: FirmwareUpdateMetaDataCCGet, - ): Promise { - // This method will only be called under two circumstances: - // 1. The node is currently busy responding to a firmware update request -> remember the request - if (this.isFirmwareUpdateInProgress()) { - this._firmwareUpdatePrematureRequest = command; - return; - } - - // 2. No firmware update is in progress -> abort - this.driver.controllerLog.logNode(this.id, { - message: - `Received Firmware Update Get, but no firmware update is in progress. Forcing the node to abort...`, - direction: "inbound", - }); - - // Since no update is in progress, we need to determine the fragment size again - const fcc = new FirmwareUpdateMetaDataCC(this.driver, { - nodeId: this.id, - }); - const fragmentSize = this.driver.computeNetCCPayloadSize(fcc) - - 2 // report number - - (fcc.version >= 2 ? 2 : 0); // checksum - const fragment = randomBytes(fragmentSize); - try { - await this.sendCorruptedFirmwareUpdateReport( - command.reportNumber, - fragment, - ); - } catch { - // ignore - } - } - - private async handleFirmwareUpdateMetaDataGet( - command: FirmwareUpdateMetaDataCCMetaDataGet, - ): Promise { - const endpoint = this.getEndpoint(command.endpointIndex) ?? this; - - // We are being queried, so the device may actually not support the CC, just control it. - // Using the commandClasses property would throw in that case - const api = endpoint - .createAPI(CommandClasses["Firmware Update Meta Data"], false) - .withOptions({ - // Answer with the same encapsulation as asked, but omit - // Supervision as it shouldn't be used for Get-Report flows - encapsulationFlags: command.encapsulationFlags - & ~EncapsulationFlags.Supervision, - }); - - // We do not support the firmware to be upgraded. - await api.reportMetaData({ - manufacturerId: this.driver.options.vendor?.manufacturerId - ?? 0xffff, - firmwareUpgradable: false, - hardwareVersion: this.driver.options.vendor?.hardwareVersion ?? 0, - }); - } - private recentEntryControlNotificationSequenceNumbers: number[] = []; private handleEntryControlNotification( command: EntryControlCCNotification, diff --git a/packages/zwave-js/src/lib/node/utils.ts b/packages/zwave-js/src/lib/node/utils.ts index 6be088f67d33..fc542fa2e1b6 100644 --- a/packages/zwave-js/src/lib/node/utils.ts +++ b/packages/zwave-js/src/lib/node/utils.ts @@ -15,6 +15,7 @@ import { getCCName, } from "@zwave-js/core"; import type { ZWaveApplicationHost } from "@zwave-js/host"; +import { type Task } from "../driver/Task"; function getValue( applHost: ZWaveApplicationHost, @@ -358,3 +359,8 @@ export function getDefinedValueIDsInternal( // Translate the remaining value IDs before exposing them to applications return ret.map((id) => translateValueID(applHost, node, id)); } + +/** Checks if a task belongs to a route rebuilding process */ +export function isFirmwareUpdateOTATask(t: Task): boolean { + return t.tag?.id === "firmware-update-ota"; +} diff --git a/test/run.ts b/test/run.ts index 551207409ae6..185bda23d839 100644 --- a/test/run.ts +++ b/test/run.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ import { wait as _wait } from "alcalzone-shared/async"; import path from "node:path"; import "reflect-metadata"; @@ -66,6 +65,8 @@ const driver = new Driver(port, { .on("error", console.error) .once("driver ready", async () => { // Test code goes here + await wait(5000); + await driver.controller.hardReset(); }) .once("bootloader ready", async () => { // What to do when stuck in the bootloader