/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { CommissioningClient } from "#behavior/system/commissioning/CommissioningClient.js"; import { NetworkClient } from "#behavior/system/network/NetworkClient.js"; import { BasicInformationClient } from "#behaviors/basic-information"; import { OtaSoftwareUpdateRequestorClient } from "#behaviors/ota-software-update-requestor"; import { Endpoint } from "#endpoint/Endpoint.js"; import type { ClientNode } from "#node/ClientNode.js"; import { Node } from "#node/Node.js"; import type { ServerNode } from "#node/ServerNode.js"; import { causedBy, ClosedError, Duration, Hours, isDeepEqual, Logger, MatterError, Millis, Minutes, Seconds, Time, Timer, } from "@matter/general"; import { Fabric, PeerAddress, Write, WriteResult } from "@matter/protocol"; import { EndpointNumber, FabricIndex, NodeId, VendorId } from "@matter/types"; import { OtaSoftwareUpdateRequestor } from "@matter/types/clusters/ota-software-update-requestor"; const logger = new Logger("OTAAnnouncements"); export class OtaAnnouncements { #announcementQueue = new Array(); #announcementTimer?: Timer; #announcementDelayTimer: Timer; #ownNodeId: NodeId; #ownFabricIndex: FabricIndex; #ownVendorId: VendorId; #node: ServerNode; #otaProviderEndpoint: EndpointNumber; #announcementInterval?: Duration; #currentAnnouncementPromise?: Promise; constructor(endpoint: Endpoint, ownFabric: Fabric) { this.#node = Node.forEndpoint(endpoint) as ServerNode; this.#ownNodeId = ownFabric.rootNodeId; this.#ownFabricIndex = ownFabric.fabricIndex; this.#ownVendorId = ownFabric.rootVendorId; this.#otaProviderEndpoint = endpoint.number; // When announcing to multiple nodes, min 1s pause between, let's do some more, but no need for random this.#announcementDelayTimer = Time.getTimer("OTA Node announcement delay", Seconds(10), () => this.#processQueueEntry(), ); } /** * Set the interval to a time value, or to `Instant` to disable announcements */ set interval(interval: Duration | undefined) { if (!interval) { this.#announcementInterval = undefined; this.#announcementTimer?.stop(); this.#announcementTimer = undefined; return; } if (interval < Hours(24)) { logger.warn("Announcements interval is too short, consider increasing it to at least 24 hours."); interval = Hours(24); } if (interval === this.#announcementInterval) { return; } // The daily start time should have a random jitter of + >= 60s, so just increase the time randomly a bit this.#announcementInterval = Millis(interval + Seconds(Math.floor(Math.random() * 120) + 60)); this.#announcementTimer?.stop(); const initialDelay = Millis(Seconds(Math.floor(Math.random() * 300)) + Minutes(10)); logger.debug(`Initial OTA announcement delay is ${Duration.format(initialDelay)}`); this.#announcementTimer = Time.getTimer("Delay for initial OTA announcement", initialDelay, () => this.#initializeAnnouncements(), ).start(); } #initializeAnnouncements() { if (this.#announcementInterval === undefined) { return; } this.#announcementTimer?.stop(); this.#announcementTimer = Time.getTimer("OTA All Nodes announcement timer", this.#announcementInterval, () => this.#queueAllPeers(), ); this.#queueAllPeers(); } #queueAllPeers() { if (this.#announcementTimer === undefined) { return; } this.#announcementTimer.stop(); for (const peer of this.#node.peers) { if (!peer.lifecycle.isCommissioned || !peer.lifecycle.isOnline) { continue; } const peerAddress = PeerAddress(peer.maybeStateOf(CommissioningClient)?.peerAddress); if (peerAddress === undefined || this.#announcementQueue.some(p => PeerAddress.is(p, peerAddress))) { continue; } this.#queuePeer(peerAddress); } this.#announcementTimer.start(); } // Queue a peer because processing is delayed and better to check /get peer anew when we process it #queuePeer(peerAddress: PeerAddress) { if (this.#announcementTimer === undefined) { return; } this.#announcementQueue.push(peerAddress); logger.debug("Queued peer", peerAddress, "for OTA announcement;", this.#announcementQueue.length, "queued"); if (this.#announcementQueue.length > 0 && !this.#announcementTimer.isRunning) { this.#announcementDelayTimer.start(); } } async #processQueueEntry() { if (this.#currentAnnouncementPromise !== undefined) { return; } if (this.#announcementQueue.length === 0) { // nothing more to process, we are done this.#announcementDelayTimer.stop(); return; } const peerAddress = this.#announcementQueue[0]; try { this.#currentAnnouncementPromise = this.#announceOtaProvider(peerAddress); await this.#currentAnnouncementPromise; } catch (error) { logger.warn(`Error announcing OTA provider to ${peerAddress}`, error); } finally { this.#currentAnnouncementPromise = undefined; } this.#announcementQueue.shift(); if (this.#announcementQueue.length > 0) { this.#announcementDelayTimer.start(); } } async #announceOtaProvider(peerAddress: PeerAddress) { const peer = this.#node.peers.get(peerAddress); if (peer === undefined || !peer.lifecycle.isCommissioned || !peer.lifecycle.isOnline) { return; } const { otaEndpoint } = this.peerApplicableForUpdate(peer) ?? {}; if (otaEndpoint === undefined) { return; } const consideredOtaProviderRecord = { providerNodeId: this.#ownNodeId, endpoint: this.#otaProviderEndpoint, fabricIndex: this.#ownFabricIndex, }; const existingOtaProviderRecord = otaEndpoint .stateOf(OtaSoftwareUpdateRequestorClient) .defaultOtaProviders.filter(({ fabricIndex }) => fabricIndex === this.#ownFabricIndex)[0]; // Check and update the default OTA provider entry and add/update it if ( existingOtaProviderRecord === undefined || !isDeepEqual(consideredOtaProviderRecord, existingOtaProviderRecord) ) { try { // Fabric scoped attribute, so we just overwrite our value WriteResult.assertSuccess( await peer.interaction.write( Write( Write.Attribute({ endpoint: otaEndpoint.number, cluster: OtaSoftwareUpdateRequestor, attributes: ["defaultOtaProviders"], value: [consideredOtaProviderRecord], }), ), ), ); logger.debug( `${existingOtaProviderRecord === undefined ? "Added" : "Updated"} default OTA provider for`, peerAddress, `on endpoint ${otaEndpoint.number}`, ); // According to specification, we should also announce ourselves via a "Simple Announcement" but this // basically only leads to all devices asking if there is an update. Our strategy is more that we tell // the device when there is any update. So let's not do this here automatically. } catch (error) { // Just log, if anything failed, we try again in 24h logger.notice("Could not set default OTA provider", error); } } } /** * Determine if we can request an update for the given node and return node meta-data needed for the process. * The Node needs to be commissioned (have a peer address), being not disabled, and having an active subscription * (to not work with outdated data). * When a node is applicable for updates, it also subscribes to softwareVersion changes to be able to react */ peerApplicableForUpdate(peer: ClientNode) { if (peer.nodeType === "group" || !peer.behaviors.has(BasicInformationClient)) { // We need more information on the node to request an update return; } const peerAddress = PeerAddress(peer.stateOf(CommissioningClient).peerAddress); if ( !peer.behaviors.has(NetworkClient) || peerAddress === undefined || peer.stateOf(NetworkClient).isDisabled || peer.behaviors.internalsOf(NetworkClient).activeSubscription === undefined ) { // Node is disabled or not connected via an active subscription logger.info( `Node`, (peerAddress ?? peer.id).toString(), `is currently not in a state where it is applicable for OTA updates (not subscribed)`, ); return; } const otaEndpoint = this.#findOtaRequestorEndpointOn(peer); if (otaEndpoint === undefined) { // Node has no OtaSoftwareUpdateRequestor cluster, so we cannot notify it about updates logger.debug( `Node`, (peerAddress ?? peer.id).toString(), ` does not support OTA updates (no OTA requestor cluster found)`, ); return; } return { otaEndpoint, peerAddress }; } /** Searches all endpoints of a peer for the OtaSoftwareUpdateRequestor cluster and returns it if found */ #findOtaRequestorEndpointOn(peer: ClientNode): Endpoint | undefined { for (const ep of peer.endpoints) { if (ep.behaviors.has(OtaSoftwareUpdateRequestorClient)) { return ep; } } } /** Announce ourselves as OTA Provider to the given node's endpoint */ async announceOtaProvider( endpoint: Endpoint, peerAddress: PeerAddress, announcementReason = OtaSoftwareUpdateRequestor.AnnouncementReason.SimpleAnnouncement, ) { try { // Find the endpoint with Requestor behavior await endpoint.commandsOf(OtaSoftwareUpdateRequestorClient).announceOtaProvider({ providerNodeId: this.#ownNodeId, vendorId: this.#ownVendorId, announcementReason, endpoint: this.#otaProviderEndpoint, }); } catch (error) { MatterError.accept(error); if (causedBy(error, ClosedError)) { logger.debug( `Node ${PeerAddress(peerAddress)}/ep${endpoint.number} closed connection during OTA announcement, will retry later`, ); } else { logger.info( `Failed to notify node ${PeerAddress(peerAddress)}/ep${endpoint.number} about available OTA update:`, error, ); } } } async close() { this.#announcementTimer?.stop(); this.#announcementDelayTimer.stop(); if (this.#currentAnnouncementPromise !== undefined) { await this.#currentAnnouncementPromise; } } }