/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { BasicInformationBehavior } from "#behaviors/basic-information"; import { DescriptorServer } from "#behaviors/descriptor"; import { OtaSoftwareUpdateProviderClient } from "#behaviors/ota-software-update-provider"; import { Endpoint } from "#endpoint/Endpoint.js"; import { ClientNodeInteraction } from "#node/client/ClientNodeInteraction.js"; import type { ClientNode } from "#node/ClientNode.js"; import { Node } from "#node/Node.js"; import type { ServerNode } from "#node/ServerNode.js"; import { ServerNodeStore } from "#storage/server/ServerNodeStore.js"; import { AsyncObservable, Bytes, CancelablePromise, Crypto, Diagnostic, Duration, Hours, ImplementationError, InternalError, Logger, MatterError, MaybePromise, Millis, Minutes, Seconds, Time, Timer, } from "@matter/general"; import { FieldElement } from "@matter/model"; import { BdxClient, BdxError, FileDesignator, Flow, OtaImageError, OtaImageHeader, OtaImageReader, PeerAddress, PersistedFileDesignator, } from "@matter/protocol"; import { FabricIndex, NodeId, StatusResponse } from "@matter/types"; import { OtaSoftwareUpdateProvider } from "@matter/types/clusters/ota-software-update-provider"; import { OtaSoftwareUpdateRequestor } from "@matter/types/clusters/ota-software-update-requestor"; import { OtaSoftwareUpdateRequestorBehavior } from "./OtaSoftwareUpdateRequestorBehavior.js"; const logger = Logger.get("OtaSoftwareUpdateRequestorServer"); const OTA_BDX_MAX_BLOCK_SIZE_TCP = 8_192; // 8192 is the max defined by the spec, we use that for TCP channels const OTA_BDX_MAX_BLOCK_SIZE_NON_TCP = 1_024; // 1024 is the max defined by the spec, we use that for non-TCP channels /** * How often a provider answering Busy is queried again before it counts as having no update; the CHIP SDK allows as many * retries, but then moves on to its next provider at once. */ const MAX_BUSY_RETRIES = 3; export interface ProviderLocation extends OtaSoftwareUpdateRequestor.ProviderLocation {} export interface ActiveProviderLocation { location: ProviderLocation; previousQueryTimestamp: number | null; metadataForNode?: Bytes; } export interface UpdateInProgressDetails { newSoftwareVersion: number; location?: ProviderLocation; updateToken?: Bytes; } /** How a query of a provider ended, which decides whether the requestor returns to Idle. */ enum QueryOutcome { /** Nothing further is pending. */ Finished, /** The provider answered Busy and a retry is armed; the requestor waits in DelayedOnQuery. */ Delayed, /** An update is prepared to be applied once the query's transaction finishes. */ ApplyPrepared, } enum ScheduleReason { NewQuery, NoUpdateAvailable, Busy, Announced, Commissioned, } class OtaDownloadError extends MatterError { bytesTransferred: number = 0; totalBytesLength?: number; constructor( public readonly code: number, message: string, ) { super(`OTA Download Error ${code}: ${message}`); } } const schema = OtaSoftwareUpdateRequestorBehavior.schema.extend( {}, FieldElement( { name: "activeOtaProviders", type: "list", quality: "N", conformance: "M" }, FieldElement( { name: "entry", type: "struct" }, FieldElement({ name: "location", type: "ProviderLocation", conformance: "M" }), FieldElement({ name: "previousQueryTimestamp", type: "uint64", quality: "X", conformance: "M" }), FieldElement({ name: "metadataForNode", type: "octstr", conformance: "O" }), ), ), FieldElement( { name: "updateInProgressDetails", type: "struct", quality: "NX", conformance: "M" }, FieldElement({ name: "location", type: "ProviderLocation", conformance: "O" }), FieldElement({ name: "newSoftwareVersion", type: "uint32" }), FieldElement({ name: "updateToken", type: "octstr", conformance: "O" }), ), ); /** * This is the default server implementation of {@link OtaSoftwareUpdateRequestorBehavior}. * * To use OTA updates for matter.js based devices, you need to implement "applying the update" yourself! * The default implementation provides anything needed to check for updates and transfer new updates, according to the * Matter specification, but applying the update is too specific to your use case and the environment where the device * is used. * * The following custom state attributes are available to configure the behavior of the cluster: * * {@link updateQueryInterval}: Interval to check for updates, default: 24h as defined in Matter specification * * * {@link transferProtocolsSupported}: List of Transfer protocols that are announced as supported. By default, we only * export BDX; we also support HTTPS (but basically no one else) * * * {@link canConsent}: Can the node request consent from the user for the update itself? Default is set to true, and the * controller needs to take care to get the necessary consent when calling {@link requestUserConsent}. * * * {@link downloadLocation}: Option to provide a custom storage location (also backed by an own Storage solution) to store * the received update file in. If not provided, a default location is used. * * * For this the following extension points exist that need to be implemented: * * {@link requestUserConsent}: This method is needed to be implemented when you set {@link canConsent} to true and needs to * implement user consent gathering. * * * {@link applyUpdate}: The method is called with the new SoftwareVersion and the PersistedFileDescriptor where a downloaded * update is placed and needs to trigger the update process including shutdown and restart of the node and also * sending "bootReason" event after the update! It runs in its own transaction after the record of the update is * stored. If the update fails, or the node keeps running the old version, it deletes the downloaded file: a file * left behind is applied again at the next query without asking the provider, so no `NotifyUpdateApplied` * follows. After a restart into the new version the requestor discards the file itself. If it throws, the * requestor logs the error, forgets the update and returns to idle. * * * {@link validateUpdateFile}: This method in default implementation reads the received OTA file and validates header and * checksums and basic details. Override this method and use this.getDownloadLocation() for access if any custom * validations are needed */ export class OtaSoftwareUpdateRequestorServer extends OtaSoftwareUpdateRequestorBehavior { declare protected internal: OtaSoftwareUpdateRequestorServer.Internal; declare readonly state: OtaSoftwareUpdateRequestorServer.State; declare readonly events: OtaSoftwareUpdateRequestorServer.Events; // Enhance the Schema to store the flag that we expect an upgrade to happen static override readonly schema = schema; override async initialize() { // When starting up, and we have an unknown state (which is default), we set it to idle if (this.state.updateState === OtaSoftwareUpdateRequestor.UpdateState.Unknown) { this.state.updateState = OtaSoftwareUpdateRequestor.UpdateState.Idle; this.state.updateStateProgress = null; } // Make sure to add the needed device type for this endpoint (await this.agent.load(DescriptorServer)).addDeviceTypes("OtaRequestor"); // Start and Stop the active queries based on the updatePossible flag this.reactTo(this.events.updatePossible$Changed, this.#enableOrDisableUpdates); // Validate and handle changes to the defaultOtaProviders list this.reactTo(this.events.defaultOtaProviders$Changing, this.#assertDefaultOtaProviders); this.reactTo(this.events.defaultOtaProviders$Changed, this.#handleUpdatedDefaultOtaProviders); const node = Node.forEndpoint(this.endpoint); this.reactTo(node.lifecycle.online, this.#online); this.reactTo(node.lifecycle.goingOffline, this.#goingOffline); // After commissioningComplete we should do an update query 30s later if anything was configured this.reactTo(node.lifecycle.commissioned, this.#scheduleInitialQuery); this.internal.applyPreparedUpdate = this.callback(this.#applyPreparedUpdate, { offline: true }); this.internal.updateProgress = this.callback(this.#updateProgress, { lock: true }); } async getDownloadLocation(): Promise { if (this.internal.downloadLocation === undefined) { // Initialize storage for downloading the update files if not already provided if (this.state.downloadLocation === undefined) { const nodeStore = this.env.get(ServerNodeStore); const blobDriver = await nodeStore.bdxStore(); const { productId, vendorId } = this.#basicInformationState(); this.internal.downloadLocation = new PersistedFileDesignator( `ota-${vendorId}-${productId}`, blobDriver, ); } else { this.internal.downloadLocation = this.state.downloadLocation; } } return this.internal.downloadLocation; } /** When we come back online after an update, we notify that the update was applied */ async #online() { // If we have no active providers, but default ones, we add the default one for our fabric (if any) // This makes sure that we retry removed defaltProviders at least once after a restart if (this.state.activeOtaProviders.length === 0 && this.state.defaultOtaProviders.length > 0) { for (const provider of this.state.defaultOtaProviders) { await this.#addActiveOtaProvider(provider); } logger.debug(`No active OTA providers, adding ${this.state.activeOtaProviders.length} default providers`); } // Register an initial update query if we have at least one provider // We choose 120s to make sure that also after a restart we respect to not query more often than 120s as per spec if (this.state.activeOtaProviders.length > 0) { this.#scheduleUpdateQuery( this.state.updateInProgressDetails === null ? Seconds(Math.floor(Math.random() * 599) + 120) : undefined, ); // when we did not get just started after an update check for updates with a random delay of 2-12mins } // If we have an update in progress, check if the update was applied if (this.state.updateInProgressDetails !== null) { await this.#handlePreviousUpdateOnStart(this.state.updateInProgressDetails); this.state.updateInProgressDetails = null; } } #goingOffline() { this.internal.updateQueryTimer?.stop(); } #scheduleInitialQuery() { this.#scheduleUpdateQuery(Seconds(30), ScheduleReason.Commissioned); } async #handlePreviousUpdateOnStart({ newSoftwareVersion, location, updateToken }: UpdateInProgressDetails) { const { productId, softwareVersion } = this.#basicInformationState(); if (newSoftwareVersion !== softwareVersion) { logger.warn( `The device restarted while an update was in progress, but new softwareVersion is not as expected (${newSoftwareVersion} != ${softwareVersion}).`, ); this.events.stateTransition.emit( { previousState: OtaSoftwareUpdateRequestor.UpdateState.Applying, newState: OtaSoftwareUpdateRequestor.UpdateState.Idle, reason: OtaSoftwareUpdateRequestor.ChangeReason.Failure, targetSoftwareVersion: null, }, this.context, ); this.#scheduleUpdateQuery( Seconds(Math.floor(Math.random() * 599) + 120), ScheduleReason.Busy, // Handle failed update like a busy provider to retry location, ); // check again on the last location a bit earlier return; } this.#emitVersionAppliedEvent(productId, softwareVersion); this.events.stateTransition.emit( { previousState: OtaSoftwareUpdateRequestor.UpdateState.Applying, newState: OtaSoftwareUpdateRequestor.UpdateState.Idle, reason: OtaSoftwareUpdateRequestor.ChangeReason.Success, targetSoftwareVersion: null, }, this.context, ); if (location === undefined || updateToken === undefined) { // No information from where the update came, so do not notify return; } try { const ep = await this.#connectOtaProviderFor(location); await ep .commandsOf(OtaSoftwareUpdateProviderClient) .notifyUpdateApplied({ softwareVersion: newSoftwareVersion, updateToken }); logger.info( `Notified OTA Provider`, Diagnostic.dict(location), `that update to version ${newSoftwareVersion} was applied`, ); } catch (error) { MatterError.accept(error); // Just log errors because the call is not mandatory logger.info(`Could not call notifyUpdateApplied on`, Diagnostic.dict(location), error); } } /** Enables or disables the update queries based on the new value of updatePossible */ #enableOrDisableUpdates(newValue: boolean) { if (newValue) { if ( !this.internal.updateQueryTimer?.isRunning || this.state.updateState === OtaSoftwareUpdateRequestor.UpdateState.Idle ) { const seconds = Seconds(Math.floor(Math.random() * 599) + 60); // random delay 1..11min logger.info(`Enabling OTA update queries because updatePossible was set to true, next try in`, seconds); this.#scheduleUpdateQuery(seconds); } } else { logger.info("Disabling OTA update queries because updatePossible was set to false."); this.internal.updateQueryTimer?.stop(); this.internal.updateQueryTimer = undefined; // Nothing is scheduled any more, so a Busy provider's retry is no longer awaited if (this.state.updateState === OtaSoftwareUpdateRequestor.UpdateState.DelayedOnQuery) { this.#resetStateToIdle(); } } } /** Validate changes to defaultOtaProviders, ensure only one entry per FabricIndex */ #assertDefaultOtaProviders(newValues: ProviderLocation[]) { const seen = new Set(); for (const { fabricIndex } of newValues) { if (seen.has(fabricIndex)) { throw new StatusResponse.ConstraintErrorError( `Only one defaultOtaProviders entry per FabricIndex ${fabricIndex} is allowed.`, ); } seen.add(fabricIndex); } } /** Handle changes to defaultOtaProviders list, ensure we add a changed entry to the list of active providers */ #handleUpdatedDefaultOtaProviders(newValues: ProviderLocation[]) { const updatedProvider = newValues.find(({ fabricIndex }) => fabricIndex === this.context.fabric); if (updatedProvider !== undefined) { logger.info( `Fabric ${this.context.fabric} default OTA provider changed to`, Diagnostic.dict(updatedProvider), ); this.#addActiveOtaProvider(updatedProvider).catch(error => logger.info("Adding default OTA provider failed", error), ); } // If we had no active providers before, we schedule an update query if (this.internal.updateQueryTimer === undefined && this.state.activeOtaProviders.length > 0) { this.#scheduleUpdateQuery(Seconds(Math.floor(Math.random() * 599) + 60), ScheduleReason.Announced); // random delay 1..11min } } /** * Default implementation for the announceOtaProvider command. * A Node announced itself as OTA Update Provider, so schedule an update check with it. * Depending on the announcementReason, we schedule the update query earlier with this provider or wait for the * next regular check. */ override async announceOtaProvider({ providerNodeId, vendorId, announcementReason, metadataForNode, endpoint, }: OtaSoftwareUpdateRequestor.AnnounceOtaProviderRequest) { // Require an associated Fabric const fabricIndex = this.context.fabric; if (!fabricIndex) { throw new StatusResponse.UnsupportedAccessError( "announceOtaProvider not allowed without an accessing fabric.", ); } const provider: ProviderLocation = { fabricIndex, providerNodeId, endpoint }; // Remember this provider as active to use it potentially with precedence await this.#addActiveOtaProvider(provider, metadataForNode); logger.info( `${announcementReason === OtaSoftwareUpdateRequestor.AnnouncementReason.UrgentUpdateAvailable ? "Urgent " : ""}OTA Provider announcement received:`, Diagnostic.dict({ announcementReason: OtaSoftwareUpdateRequestor.AnnouncementReason[announcementReason], ...provider, vendorId, }), ); // Schedule the update query before the async peers.forAddress call below. This ensures any existing // timer (e.g. the 30s post-commissioning timer) is replaced before async operations give MockTime (or // real delays under load) a chance to fire the old timer prematurely. if (announcementReason !== OtaSoftwareUpdateRequestor.AnnouncementReason.SimpleAnnouncement) { // If Urgent or UpdateAvailable, we schedule an update query earlier as we would have done before const delay = this.state.announcedUpdateQueryDelay ?? Seconds(Math.floor(Math.random() * 600) + 1); logger.info(`Scheduling urgent update query in`, delay); this.#scheduleUpdateQuery(delay, ScheduleReason.Announced, provider); } else { // Make sure we initialize the query timer if none was existing this.#scheduleUpdateQuery(); } const peerAddress = PeerAddress({ nodeId: providerNodeId, fabricIndex }); await (Node.forEndpoint(this.endpoint) as ServerNode).peers.forAddress(peerAddress); // Initialize the client node and store address } /** Adds or updates an active OTA provider entry for a fabric index */ async #addActiveOtaProvider(provider: ProviderLocation, metadataForNode?: Bytes) { const { fabricIndex, providerNodeId } = provider; const activeProviderIndex = this.state.activeOtaProviders.findIndex( p => p.location.fabricIndex === fabricIndex && p.location.providerNodeId === providerNodeId, ); const activeProviderEntry: ActiveProviderLocation = { location: provider, previousQueryTimestamp: null, // We set to null to try this provider first next time metadataForNode, }; if (activeProviderIndex === -1) { this.state.activeOtaProviders.push(activeProviderEntry); //this.state.activeOtaProviders = [...this.state.activeOtaProviders, activeProviderEntry]; } else { this.state.activeOtaProviders[activeProviderIndex] = activeProviderEntry; } const peerAddress = PeerAddress({ nodeId: providerNodeId, fabricIndex }); await (Node.forEndpoint(this.endpoint) as ServerNode).peers.forAddress(peerAddress); // Initialize client node and store address } /** Removes an OTA provider from the active and default list */ #removeOtaProvider(provider: ProviderLocation) { const { fabricIndex, providerNodeId } = provider; const activeProviderIndex = this.state.activeOtaProviders.findIndex( p => p.location.fabricIndex === fabricIndex && p.location.providerNodeId === providerNodeId, ); if (activeProviderIndex !== -1) { this.state.activeOtaProviders.splice(activeProviderIndex, 1); } const defaultProviderIndex = this.state.defaultOtaProviders.findIndex( p => p.fabricIndex === fabricIndex && p.providerNodeId === providerNodeId, ); if (defaultProviderIndex !== -1) { this.state.defaultOtaProviders.splice(defaultProviderIndex, 1); } } /** Marks an OTA provider as active but with no update available (timestamp 0) */ #markActiveOtaProviderNoUpdate(provider: ProviderLocation) { const { fabricIndex, providerNodeId } = provider; const activeProviderIndex = this.state.activeOtaProviders.findIndex( p => p.location.fabricIndex === fabricIndex && p.location.providerNodeId === providerNodeId, ); if (activeProviderIndex !== -1) { this.state.activeOtaProviders[activeProviderIndex].previousQueryTimestamp = Time.nowMs; } else { this.state.activeOtaProviders.push({ location: provider, previousQueryTimestamp: Time.nowMs, }); } } /** * Schedule an update query after the given delay (or latest as defined as update interval if no delay is provided) * and optionally for a specific provider (otherwise the most recently seen active provider or the default provider * is used). * * @returns whether a query is scheduled when this returns */ #scheduleUpdateQuery(delay?: Duration, reason = ScheduleReason.NewQuery, provider?: ProviderLocation): boolean { if (provider !== undefined) { this.internal.selectedProviderLocation = provider; if (reason === ScheduleReason.Busy) { this.internal.providerRetryCount++; } } else { this.internal.providerRetryCount = 0; } if (this.internal.updateQueryTimer) { if (delay === undefined && this.internal.updateQueryTimer.isRunning) { // already scheduled, so lets keep that one return true; } this.internal.updateQueryTimer.stop(); this.internal.updateQueryTimer = undefined; } if (!this.state.updatePossible || !this.state.activeOtaProviders.length) { return false; } if (delay === undefined && this.state.updateState !== OtaSoftwareUpdateRequestor.UpdateState.Idle) { logger.info( `Cannot schedule update query, current state is ${OtaSoftwareUpdateRequestor.UpdateState[this.state.updateState]}`, ); return false; } if (delay === undefined) { delay = this.state.updateQueryInterval; } logger.info(`Scheduling OTA update query in ${Duration.format(delay)} (reason "${ScheduleReason[reason]}")`); this.internal.updateQueryTimer = this.reactorTimer("OTA Request", delay, this.#performUpdateQuery).start(); return true; } /** Choose the next OTA provider to use for an update */ #chooseUpdateProvider(): ProviderLocation | undefined { // If we have a selected provider, use that one once if (this.internal.selectedProviderLocation !== undefined) { const selected = this.internal.selectedProviderLocation; logger.debug(`Choosing pre-selected provider`, Diagnostic.dict(selected)); this.internal.selectedProviderLocation = undefined; return selected; } // We use the most recently seen active provider first, else we fall back to the default providers if (this.state.activeOtaProviders.length > 0) { const sorted = this.state.activeOtaProviders.sort( // Sort by oldest timestamp to try all over time (a, b) => (a.previousQueryTimestamp ?? 0) - (b.previousQueryTimestamp ?? 0), ); const location = sorted[0].location; logger.debug( `Choosing active provider`, PeerAddress({ nodeId: location.providerNodeId, fabricIndex: location.fabricIndex }), `ep${location.endpoint}`, ); return location; } return; } /** Helper to connect to an OTA provider, initialize an OTA Provider Cluster Client, and return this endpoint */ async #connectOtaProviderFor(location: ProviderLocation) { const { providerNodeId, fabricIndex, endpoint } = location; const peerAddress = PeerAddress({ nodeId: providerNodeId, fabricIndex }); logger.debug(`Establish connection for OTA to ${peerAddress}`); const node = await (Node.forEndpoint(this.endpoint) as ServerNode).peers.forAddress(peerAddress); const ep = node.endpoints.require(endpoint); ep.behaviors.require(OtaSoftwareUpdateProviderClient); return ep; } /** Perform an actual update query unless one is still running */ async #performUpdateQuery() { // A query can run for hours while it waits for a delay the provider requested; a query timer that fires // meanwhile must not start a second one if (this.internal.queryRunning) { logger.info("OTA update query is still running, skipping the new query"); return; } this.internal.queryRunning = true; try { await this.#queryForUpdate(); } finally { this.internal.queryRunning = false; } } async #queryForUpdate() { // Every query passes here, so none starts while applyUpdate runs outside the query's transaction if (this.state.updateState === OtaSoftwareUpdateRequestor.UpdateState.Applying) { logger.info("OTA update is being applied, skipping update query"); return; } const downloadLocation = await this.getDownloadLocation(); if (await downloadLocation.exists()) { let otaHeader: OtaImageHeader | undefined; try { otaHeader = await this.validateUpdateFile(); } catch (error) { logger.notice(`OTA update file is invalid:`, error); await downloadLocation.delete(); otaHeader = undefined; // Should be the case anyway, but let's make sure } if (otaHeader !== undefined) { // We already have a file, and a version is newer than the current, apply it const { softwareVersion } = otaHeader; logger.info(`OTA update file is already downloaded and valid, applying version ${softwareVersion}.`); this.#prepareApplyUpdate(softwareVersion, downloadLocation); return; } } // Reset progress and update state for querying this.state.updateStateProgress = null; const provider = this.#chooseUpdateProvider(); if (provider === undefined) { logger.info("No OTA Provider configured, cannot query for updates."); this.internal.updateQueryTimer?.stop(); this.internal.updateQueryTimer = undefined; return; } this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.Querying, OtaSoftwareUpdateRequestor.ChangeReason.Success, ); let outcome = QueryOutcome.Finished; try { // Connect to the provider and query for updates outcome = await this.#queryOtaProvider(await this.#connectOtaProviderFor(provider), provider); } catch (error) { logger.warn(`OTA provider communication failed to`, Diagnostic.dict(provider), error); // Handle same as Update unavailable this.#markActiveOtaProviderNoUpdate(provider); } if (outcome === QueryOutcome.Finished) { this.#resetStateToIdle(); } } /** * Query the given OTA provider for an update and handle all non-UpdateAvailable results and error cases. * * @returns the update the provider offers, {@link QueryOutcome.Delayed} while a Busy retry is armed, or * `undefined` when there is none */ async #updateAvailableFromProvider( query: OtaSoftwareUpdateProvider.QueryImageRequest, ep: Endpoint, providerLocation: ProviderLocation, ) { // Send the QueryImage command to the provider const queryResponse = await ep.commandsOf(OtaSoftwareUpdateProviderClient).queryImage(query); const { status, delayedActionTime = 0, imageUri, softwareVersion, softwareVersionString, updateToken, userConsentNeeded, } = queryResponse; switch (status) { case OtaSoftwareUpdateProvider.Status.Busy: case OtaSoftwareUpdateProvider.Status.NotAvailable: // A Busy provider is preparing an update, so it is worth asking again rather than waiting for the next // regular query if ( status === OtaSoftwareUpdateProvider.Status.Busy && this.internal.providerRetryCount < MAX_BUSY_RETRIES && this.#scheduleUpdateQuery( Millis(Math.max(Seconds(delayedActionTime), this.state.minimumQueryInterval)), ScheduleReason.Busy, providerLocation, ) ) { this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.DelayedOnQuery, OtaSoftwareUpdateRequestor.ChangeReason.DelayByProvider, ); return QueryOutcome.Delayed; } this.#markActiveOtaProviderNoUpdate(providerLocation); this.#resetStateToIdle(); return; case OtaSoftwareUpdateProvider.Status.DownloadProtocolNotSupported: logger.info( `Removing OTA Provider because no supported download protocol was accepted`, Diagnostic.dict(providerLocation), ); this.#removeOtaProvider(providerLocation); // No need to query this one again this.#resetStateToIdle(); return; } if ( imageUri === undefined || softwareVersion === undefined || softwareVersionString === undefined || updateToken === undefined ) { logger.info(`Invalid OTA Provider response: mandatory fields missing. Ignoring update.`); this.#markActiveOtaProviderNoUpdate(providerLocation); // Use others next time this.#resetStateToIdle(OtaSoftwareUpdateRequestor.ChangeReason.Failure); return; } if (imageUri.startsWith("bdx://")) { const { providerNodeId } = providerLocation; const { sourceNodeId } = FileDesignator.fromBdxUri(imageUri); if (sourceNodeId !== providerNodeId) { logger.info( `Invalid OTA Provider response: BDX URI source node ID ${NodeId.strOf( sourceNodeId, )} does not match provider node ID ${NodeId.strOf(providerNodeId)}. Ignoring update.`, ); this.#markActiveOtaProviderNoUpdate(providerLocation); // Use others next time this.#resetStateToIdle(OtaSoftwareUpdateRequestor.ChangeReason.Failure); return; } } const { softwareVersion: currentSoftwareVersion } = query; if (softwareVersion <= currentSoftwareVersion) { logger.info( `Ignoring OTA Provider response: softwareVersion ${softwareVersion} not newer than current ${currentSoftwareVersion}`, ); this.#markActiveOtaProviderNoUpdate(providerLocation); this.#resetStateToIdle(); return; } if (userConsentNeeded) { if (!this.state.canConsent) { // Ignore update because we cannot do a consent, should normally never happen logger.info(`OTA update requires user consent, but we cannot consent, ignoring update.`); this.#markActiveOtaProviderNoUpdate(providerLocation); this.#resetStateToIdle(); return; } if (this.#basicInformationState().localConfigDisabled) { logger.info("OTA update requires user consent, but local config is disabled. Update cancelled."); this.#resetStateToIdle(OtaSoftwareUpdateRequestor.ChangeReason.Failure); return; } } return { imageUri, softwareVersion, softwareVersionString, updateToken, userConsentNeeded, delayedActionTime, }; } /** * Sends the ApplyUpdate command to the provider and handles the result, including any delays or retries */ async #validateApplyUpdate( applyRequest: OtaSoftwareUpdateProvider.ApplyUpdateRequest, ep: Endpoint, fileDesignator: PersistedFileDesignator, ): Promise { this.state.updateStateProgress = null; this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.Applying, OtaSoftwareUpdateRequestor.ChangeReason.Success, applyRequest.newVersion, ); let action: OtaSoftwareUpdateProvider.ApplyUpdateAction; let applyDelayedActionTime: number; try { // Ask the provider if we are allowed to proceed with the update ({ action, delayedActionTime: applyDelayedActionTime } = await ep .commandsOf(OtaSoftwareUpdateProviderClient) .applyUpdateRequest(applyRequest)); } catch (error) { logger.info(`OTA Provider applyUpdateRequest failed:`, error); this.#resetStateToIdle(OtaSoftwareUpdateRequestor.ChangeReason.Failure); return false; } if (action === OtaSoftwareUpdateProvider.ApplyUpdateAction.Discontinue) { // Provider told us to cancel the update progress try { await fileDesignator.delete(); } catch (error) { logger.warn(`OTA update file delete failed:`, error); } this.#resetStateToIdle(); return false; } else if (action === OtaSoftwareUpdateProvider.ApplyUpdateAction.AwaitNextAction) { // Provider told us to ask again later this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.DelayedOnApply, OtaSoftwareUpdateRequestor.ChangeReason.DelayByProvider, ); this.internal.updateDelayPromise = Time.sleep( "OTAUpdateApply-AwaitNextAction", Millis(Math.min(Math.max(Seconds(applyDelayedActionTime), this.state.minimumApplyDelay), Hours(24))), ); await this.internal.updateDelayPromise; this.internal.updateDelayPromise = undefined; return await this.#validateApplyUpdate(applyRequest, ep, fileDesignator); } else if (action !== OtaSoftwareUpdateProvider.ApplyUpdateAction.Proceed) { logger.notice(`Invalid OTA Provider applyUpdateRequest response: unknown Action ${action}`); this.#resetStateToIdle(OtaSoftwareUpdateRequestor.ChangeReason.Failure); return false; } // Ok we can continue, but after a slight delay if (applyDelayedActionTime > 0) { this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.DelayedOnApply, OtaSoftwareUpdateRequestor.ChangeReason.DelayByProvider, ); this.internal.updateDelayPromise = Time.sleep( "OTAUpdateApply-DelayedByProvider", Millis(Math.min(Seconds(applyDelayedActionTime), Hours(24))), ); await this.internal.updateDelayPromise; this.internal.updateDelayPromise = undefined; } return true; } /** * Query the given OTA provider for an update and handle the result. */ async #queryOtaProvider(ep: Endpoint, providerLocation: ProviderLocation): Promise { const { vendorId, productId, softwareVersion, hardwareVersion, location, localConfigDisabled } = this.#basicInformationState(); // Sends the queryImage command to a provider to get information on available updates and validates response const updateDetails = await this.#updateAvailableFromProvider( { vendorId, productId, softwareVersion, protocolsSupported: this.state.transferProtocolsSupported, hardwareVersion, location, requestorCanConsent: this.state.canConsent && !localConfigDisabled ? true : undefined, }, ep, providerLocation, ); if (updateDetails === undefined) { return QueryOutcome.Finished; } if (updateDetails === QueryOutcome.Delayed) { return QueryOutcome.Delayed; } const { imageUri, softwareVersion: newSoftwareVersion, softwareVersionString: newSoftwareVersionString, updateToken, userConsentNeeded, delayedActionTime, } = updateDetails; const now = Time.nowMs; // Are we responsible to get User consent for the update? (mainly because we told the UpdateProvider that we // can request consent from the user ourselves if (userConsentNeeded) { this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.DelayedOnUserConsent, OtaSoftwareUpdateRequestor.ChangeReason.Success, ); try { if (!(await this.requestUserConsent(newSoftwareVersion, newSoftwareVersionString))) { // Consent denied this.#markActiveOtaProviderNoUpdate(providerLocation); this.#resetStateToIdle(); return QueryOutcome.Finished; } } catch (error) { logger.warn(`Failed to request user consent:`, error); this.#markActiveOtaProviderNoUpdate(providerLocation); this.#resetStateToIdle(OtaSoftwareUpdateRequestor.ChangeReason.Failure); return QueryOutcome.Finished; } } // The update provider requested a delay before continuing if (delayedActionTime > 0) { const delayS = now - Time.nowMs + Seconds(delayedActionTime); // Check how long we still need to wait; maybe consent took longer than the requested delay if (delayS > 0) { this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.DelayedOnQuery, OtaSoftwareUpdateRequestor.ChangeReason.DelayByProvider, ); logger.info(`Waiting for ${delayS}s before applying update`); this.internal.updateDelayPromise = Time.sleep( "OTAUpdateQuery-DelayedByProvider", Millis(Math.min(delayS, Hours(24))), ); await this.internal.updateDelayPromise; this.internal.updateDelayPromise = undefined; } } // Download the File, also checks if we already have it let fileDesignator: PersistedFileDesignator; try { fileDesignator = await this.#handleDownload(ep, imageUri, newSoftwareVersion); } catch (error) { MatterError.accept(error); logger.notice(`OTA download failed:`, error); if (error instanceof OtaDownloadError) { this.#emitDownloadErrorEvent(newSoftwareVersion, error.bytesTransferred, error.code); } this.#resetStateToIdle(OtaSoftwareUpdateRequestor.ChangeReason.Failure); try { await (await this.getDownloadLocation()).delete(); } catch (error) { MatterError.accept(error); logger.warn(`OTA download failed and deleting partial file also failed:`, error); } return QueryOutcome.Finished; } // Inform the provider that we are ready to apply the update if ( !(await this.#validateApplyUpdate( { updateToken, newVersion: newSoftwareVersion, }, ep, fileDesignator, )) ) { // Not allowed to proceed with the update, so stop here return QueryOutcome.Finished; } this.#prepareApplyUpdate(newSoftwareVersion, fileDesignator, providerLocation, updateToken); return QueryOutcome.ApplyPrepared; } async #handleBdxDownload(endpoint: Endpoint, fileDesignator: FileDesignator) { const node = Node.forEndpoint(endpoint) as ClientNode; let downloadTryCount = 0; while (true) { this.state.updateStateProgress = 0; // We try up to 3 times to download the file try { const { context, slot } = await (node.interaction as ClientNodeInteraction).initBdx(); const { messenger } = context; // Set up the OTA client, timeouts are already BDX defaults const bdx = BdxClient.asReceiver(messenger, { transferFileDesignator: fileDesignator, // We need to use the original designator here for transfer fileDesignator: await this.getDownloadLocation(), // But we store in a different location preferredDriverModes: [Flow.DriverMode.ReceiverDrive], maxBlockSize: messenger.channel.type === "tcp" ? OTA_BDX_MAX_BLOCK_SIZE_TCP : OTA_BDX_MAX_BLOCK_SIZE_NON_TCP, }); // We report progress 0..99%, 100% we only report when finished bdx.progressInfo.on(this.internal.updateProgress); bdx.progressFinished.on(bytesTransferred => logger.info(`OTA download finished after ${bytesTransferred} bytes`), ); // Wait for BDX transfer to complete await bdx.processTransfer(); slot?.close(); await context[Symbol.asyncDispose](); break; } catch (error) { logger.info(`OTA BDX download attempt ${downloadTryCount + 1} failed:`, error); if (++downloadTryCount >= 3) { const code = error instanceof BdxError ? error.code : undefined; const bdxError = new OtaDownloadError( code ?? OtaSoftwareUpdateRequestor.UpdateState.Unknown, `OTA BDX download failed after ${downloadTryCount} attempts: ${ error instanceof Error ? error.message : "Unknown error" }`, ); bdxError.cause = error; throw bdxError; } } } } async #handleHttpsDownload(uri: string) { const res = await fetch(uri); if (!res.ok || res.body === null) { throw new OtaDownloadError(res.status, `HTTP Download error: ${res.statusText}`); } const fetchStream = res.body; const contentLength = res.headers.get("content-length"); let totalBytes = contentLength ? parseInt(contentLength) : undefined; if (totalBytes === undefined || !Number.isFinite(totalBytes) || totalBytes <= 0) { totalBytes = undefined; } this.state.updateStateProgress = 0; let bytesReceived = 0; try { const progressStream = new ReadableStream({ start: controller => { const reader = fetchStream.getReader(); const read = () => { reader .read() .then(({ done, value }) => { if (done) { controller.close(); return; } bytesReceived += value?.length ?? 0; this.#updateProgress(bytesReceived, totalBytes); controller.enqueue(value); read(); }) .catch(error => { controller.error(error); }); }; read(); }, }); await (await this.getDownloadLocation()).writeFromStream(progressStream); logger.info(`OTA download finished after ${bytesReceived} bytes`); } catch (error) { const httpError = new OtaDownloadError( res.status, error instanceof Error ? error.message : "Unknown error", ); httpError.cause = error; httpError.bytesTransferred = bytesReceived; httpError.totalBytesLength = totalBytes; throw httpError; } } #updateProgress(bytesDownloaded: number, totalBytes?: number) { if (totalBytes !== undefined) { this.state.updateStateProgress = Math.floor((bytesDownloaded / totalBytes) * 99); } else { this.state.updateStateProgress = Math.floor(99 * (1 - Math.exp(-bytesDownloaded / 1_000_000))); // Approximation to show progress } } async #handleDownload( endpoint: Endpoint, uri: string, newSoftwareVersion: number, ): Promise { if (uri.startsWith("bdx://")) { if ( !this.state.transferProtocolsSupported.includes( OtaSoftwareUpdateProvider.DownloadProtocol.BdxSynchronous, ) ) { throw new InternalError("BDX Synchronous not supported"); // Should never happen } this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.Downloading, OtaSoftwareUpdateRequestor.ChangeReason.Success, newSoftwareVersion, ); const { fileDesignator: transferFd } = FileDesignator.fromBdxUri(uri); await this.#handleBdxDownload(endpoint, transferFd); } else if (uri.startsWith("https://")) { if (!this.state.transferProtocolsSupported.includes(OtaSoftwareUpdateProvider.DownloadProtocol.Https)) { throw new ImplementationError("HTTPS not supported"); } this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.Downloading, OtaSoftwareUpdateRequestor.ChangeReason.Success, newSoftwareVersion, ); await this.#handleHttpsDownload(uri); } else { throw new InternalError(`Unsupported download URI: ${uri}`); } await this.validateUpdateFile(newSoftwareVersion); // Done and also verified, we are done this.state.updateStateProgress = 100; return await this.getDownloadLocation(); } /** * Validate the update file * The default implementation does basic validations based on the matter OTA file format and expected software * version. * The method SHALL be overridden to add additional checks like validating the signature of the file. * throws an error if the file is invalid */ protected async validateUpdateFile(newSoftwareVersion?: number) { let totalSize: number | undefined; try { const blob = await (await this.getDownloadLocation()).openBlob(); totalSize = blob.size; const crypto = this.env.get(Crypto); const header = await OtaImageReader.file(blob.stream().getReader(), crypto); const { softwareVersion: otaFileSoftwareVersion } = header; if (newSoftwareVersion === undefined) { const { softwareVersion: currentSoftwareVersion } = this.#basicInformationState(); if (otaFileSoftwareVersion <= currentSoftwareVersion) { throw new OtaImageError( `OTA file software version ${otaFileSoftwareVersion} not newer than current ${currentSoftwareVersion}`, ); } } else if (otaFileSoftwareVersion !== newSoftwareVersion) { throw new OtaImageError( `OTA file software version ${otaFileSoftwareVersion} does not match expected ${newSoftwareVersion}`, ); } return header; } catch (error) { const otaDownloadError = new OtaDownloadError( OtaSoftwareUpdateRequestor.UpdateState.Unknown, `Downloaded OTA file is invalid: ${error instanceof Error ? error.message : "Unknown error"}`, ); otaDownloadError.cause = error; if (totalSize !== undefined) { otaDownloadError.bytesTransferred = totalSize; } throw otaDownloadError; } } /** * Update the state and emit the stateTransition event if the state changed. * If the new state is Idle the next update query is scheduled. */ #updateState( newState: OtaSoftwareUpdateRequestor.UpdateState, reason: OtaSoftwareUpdateRequestor.ChangeReason, targetSoftwareVersion?: number, ) { const previousState = this.state.updateState; if ( newState !== OtaSoftwareUpdateRequestor.UpdateState.Downloading && newState !== OtaSoftwareUpdateRequestor.UpdateState.Applying && newState !== OtaSoftwareUpdateRequestor.UpdateState.RollingBack ) { // targetSoftwareVersion shall only be sent in these states targetSoftwareVersion = undefined; } else if (targetSoftwareVersion === undefined) { throw new InternalError("targetSoftwareVersion must be provided for state " + newState); } if (newState !== previousState) { this.events.stateTransition.emit( { previousState, newState, reason, targetSoftwareVersion: targetSoftwareVersion ?? null, }, this.context, ); this.state.updateState = newState; } if ( newState === OtaSoftwareUpdateRequestor.UpdateState.Idle || newState === OtaSoftwareUpdateRequestor.UpdateState.Unknown ) { // We schedule the next update query this.#scheduleUpdateQuery(); this.state.updateStateProgress = null; } else if (this.state.updateStateProgress !== null) { // We reset the state progress to 0 if it was not null before this.state.updateStateProgress = 0; } } #resetStateToIdle(reason = OtaSoftwareUpdateRequestor.ChangeReason.Success) { this.#updateState(OtaSoftwareUpdateRequestor.UpdateState.Idle, reason); } #emitVersionAppliedEvent(productId: number, softwareVersion: number) { this.events.versionApplied.emit({ softwareVersion, productId }, this.context); } #basicInformationState() { const rootEndpoint = Node.forEndpoint(this.endpoint) as ServerNode; if (!rootEndpoint.behaviors.has(BasicInformationBehavior)) { throw new InternalError("BasicInformationBehavior missing"); // Should never happen } return rootEndpoint.stateOf(BasicInformationBehavior); } #emitDownloadErrorEvent(softwareVersion: number, bytesDownloaded: number, platformCode?: number) { this.events.downloadError.emit( { softwareVersion, bytesDownloaded, progressPercent: this.state.updateStateProgress, platformCode: platformCode ?? null, }, this.context, ); } /** * Record the update and apply it once the current transaction commits. * * {@link applyUpdate} runs in its own transaction so that the record is stored before it runs, and so that it holds * no lock the requestor's other reactions wait for. */ #prepareApplyUpdate( newSoftwareVersion: number, fileDesignator: PersistedFileDesignator, location?: ProviderLocation, updateToken?: Bytes, ) { this.state.updateInProgressDetails = { newSoftwareVersion, location, updateToken, }; this.#updateState( OtaSoftwareUpdateRequestor.UpdateState.Applying, OtaSoftwareUpdateRequestor.ChangeReason.Success, newSoftwareVersion, ); const { applyPreparedUpdate } = this.internal; this.context.transaction.onFinalize(() => applyPreparedUpdate?.(newSoftwareVersion, fileDesignator)); } async #applyPreparedUpdate(newSoftwareVersion: number, fileDesignator: PersistedFileDesignator) { // onFinalize also runs after a roll back, which leaves nothing prepared and no query scheduled if ( this.state.updateState !== OtaSoftwareUpdateRequestor.UpdateState.Applying || this.state.updateInProgressDetails?.newSoftwareVersion !== newSoftwareVersion ) { logger.info(`OTA update to software version ${newSoftwareVersion} was not prepared, not applying it`); this.#scheduleUpdateQuery(); return; } try { await this.applyUpdate(newSoftwareVersion, fileDesignator); } catch (error) { logger.error(`Applying OTA update to software version ${newSoftwareVersion} failed:`, error); await this.#lockState(); this.state.updateInProgressDetails = null; this.#resetStateToIdle(OtaSoftwareUpdateRequestor.ChangeReason.Failure); return; } await this.#lockState(); this.#resetStateToIdle(); } /** Wait for other writers of this state instead of failing on a synchronous write */ async #lockState() { await this.context.transaction.addResources(this); await this.context.transaction.begin(); } protected applyUpdate(_newSoftwareVersion: number, _fileDesignator: PersistedFileDesignator): MaybePromise { throw new ImplementationError("To apply the update the applyUpdate() method must be implemented."); } protected requestUserConsent( _newSoftwareVersion: number, _newSoftwareVersionString: string, ): MaybePromise { throw new ImplementationError( "Seems 'canConsent' is set, but requestUserConsent() not implemented. Declining update", ); } override async [Symbol.asyncDispose]() { this.internal.updateDelayPromise?.cancel(new MatterError("Update Requestion cluster shuts down")); await super[Symbol.asyncDispose]?.(); } } export namespace OtaSoftwareUpdateRequestorServer { export class State extends OtaSoftwareUpdateRequestorBehavior.State { /** * How long to wait before querying a provider that announced an update other than a simple one. * * Unset draws the specified random window, so that the nodes of a fabric announced to together do * not query at once. A node that knows it is alone with its provider may name a shorter wait; the * specification states a preference rather than a requirement, so a fixed value still conforms. * * @see {@link MatterSpecification.v161.Core} § 11.20.7.4.1.3 */ announcedUpdateQueryDelay?: Duration = undefined; /** * Shortest interval this requestor leaves between two `QueryImage` commands to one provider, * which also floors the `DelayedActionTime` a `Busy` answer names. * * The specification requires two minutes, which is the default. It is settable so a test * harness can observe the exchange that follows a delayed answer without waiting the delay * out; a product that lowers it does not conform. * * @see {@link MatterSpecification.v161.Core} § 11.20.3.2 */ minimumQueryInterval: Duration = Seconds(120); /** * Shortest interval this requestor leaves before re-sending an `ApplyUpdateRequest` a provider * answered `AwaitNextAction`, which also floors the `DelayedActionTime` that answer names. * * As {@link minimumQueryInterval}: the specification requires two minutes, and lowering it is * for a harness rather than for a product. * * @see {@link MatterSpecification.v161.Core} § 11.20.3.6 */ minimumApplyDelay: Duration = Minutes(2); /** * The list of OTA providers that were recently active (by announcement or by being used). * A provider that supports none of this requestor's download protocols is removed from this list and from the * default providers; one that fails or has no update is tried after the others next time. * This value is persisted. */ activeOtaProviders: ActiveProviderLocation[] = []; /** * Details of an upgrade in progress, set when the update is applied and consumed on the next start: if the node * then runs {@link UpdateInProgressDetails.newSoftwareVersion} it emits `VersionApplied` and, where the update * came from a provider with a token, reports it applied to that provider; otherwise it reports a failure and * queries again. A failing `applyUpdate` clears it. This value is persisted. */ updateInProgressDetails: UpdateInProgressDetails | null = null; /** How often to query for updates. Default is 24 hours ("daily") as proposed by spec. */ updateQueryInterval = Hours(24); /** * The transfer protocols supported by this requestor. Default is BDX Synchronous. * If it was verified that the node really have access to the public internet, HTTPS can be added as protocol * as well. */ transferProtocolsSupported = [OtaSoftwareUpdateProvider.DownloadProtocol.BdxSynchronous]; /** * If true the requestor is able to get user consent for an update. This requires the implementation of the * requestUserConsent() extension interface method. * If false the OTA provider will only provide updates that do not require user consent or where user consent * was already given. * Default is false. */ canConsent = false; /** * Optional custom persisted location as PersistedFileDescriptor to store the downloaded update files. * If not provided, the default storage context for "bdx" of the node is used with a filename like * "ota-{vendorId}-{productId}.update". * This can be used to store the files in a different persistent storage if needed. */ downloadLocation?: PersistedFileDesignator; } export class Events extends OtaSoftwareUpdateRequestorBehavior.Events { /** Emitted when a new software update file was downloaded and should now be applied. */ updateReadyToApply = AsyncObservable(); } export class Internal { /** Timer for the next update query */ updateQueryTimer: Timer | undefined; /** Whether an update query runs, including its download and the delays the provider requests */ queryRunning = false; /** * The preferred provider to use for the next update query. * Mainly used for the Busy case to reuse the same provider for the next try. */ selectedProviderLocation?: ProviderLocation; /** How often we already retried to connect to the current provider */ providerRetryCount = 0; /** * Stores the time of a current update delay to allow cancelling it */ updateDelayPromise: CancelablePromise | undefined; /** * Persisted location as PersistedFileDescriptor to store the downloaded update files. * It is initialized from the state or with an internal default on startup. */ downloadLocation!: PersistedFileDesignator; /** Reports download progress in a dedicated transaction */ updateProgress!: (bytesDownloaded: number, totalBytes?: number) => void; /** Applies a prepared update in a dedicated transaction */ applyPreparedUpdate?: (newSoftwareVersion: number, fileDesignator: PersistedFileDesignator) => void; } export declare const ExtensionInterface: { requestUserConsent(newSoftwareVersion: number, newSoftwareVersionString: string): MaybePromise; applyUpdate(newSoftwareVersion: number, fileDesignator: PersistedFileDesignator): MaybePromise; validateUpdateFile(newSoftwareVersion?: number): MaybePromise; }; }