/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Behavior } from "#behavior/Behavior.js"; import { Events as BaseEvents } from "#behavior/Events.js"; import { SoftwareUpdateManager } from "#behavior/system/software-update/SoftwareUpdateManager.js"; import { AdministratorCommissioningClient } from "#behaviors/administrator-commissioning"; import { BasicInformationClient } from "#behaviors/basic-information"; import { OperationalCredentialsClient } from "#behaviors/operational-credentials"; import { OtaSoftwareUpdateProviderServer } from "#behaviors/ota-software-update-provider"; import { IdentityConflictError } from "#endpoint/errors.js"; import { ClientStructure } from "#node/client/ClientStructure.js"; import type { ClientNode } from "#node/ClientNode.js"; import { IdentityService } from "#node/server/IdentityService.js"; import type { ServerNode } from "#node/ServerNode.js"; import { causedBy, ClassExtends, Crypto, CRYPTO_PBKDF_ITERATIONS_MIN, deepCopy, Diagnostic, Duration, ImplementationError, isObject, Logger, MatterError, Millis, Minutes, NotImplementedError, Observable, Seconds, ServerAddress, Time, Timestamp, } from "@matter/general"; import { bool, datatype, devtypeId, duration, fabricIdx, field, listOf, mandatory, map16, nodeId, nonvolatile, string, systimeMs, uint16, uint32, uint8, vendorId, } from "@matter/model"; import type { ClientInteraction, PeerDescriptor, SupportedTransportsBitmap, Val } from "@matter/protocol"; import { CommissioningMode, ControllerCommissioner, ControllerCommissioningFlow, ControllerCommissioningFlowOptions, DeviceAttestationValidator, DiscoveryData, Fabric, FabricAuthority, FabricManager, FabricRemovedError, LocatedNodeCommissioningOptions, OperationalAddress, PaseClient, Peer, PeerAddress, PeerInitiatedCloseError, PeerLeftError, PeerMessageMissingError, PeerSet, PeerTimingParameters, PeerAddress as ProtocolPeerAddress, SessionParameters as ProtocolSessionParameters, Read, SessionIntervals, Subscribe, } from "@matter/protocol"; import { CaseAuthenticatedTag, CommissioningFlowType, DeviceTypeId, DiscoveryCapabilitiesSchema, FabricIndex, ManualPairingCodeCodec, NodeId, QrPairingCodeCodec, Status, StatusResponseError, VendorId, } from "@matter/types"; import { AdministratorCommissioning } from "@matter/types/clusters/administrator-commissioning"; import { OperationalCredentials } from "@matter/types/clusters/operational-credentials"; import { ControllerBehavior } from "../controller/ControllerBehavior.js"; import { NetworkClient } from "../network/NetworkClient.js"; import { RemoteDescriptor } from "./RemoteDescriptor.js"; const logger = Logger.get("CommissioningClient"); function storedMillis(value: unknown) { return typeof value === "number" ? Millis(value) : undefined; } /** * Client functionality related to commissioning. * * Updates node state based on commissioning status and commissions new nodes. */ export class CommissioningClient extends Behavior { declare internal: CommissioningClient.Internal; declare readonly state: CommissioningClient.State; declare readonly events: CommissioningClient.Events; static override readonly early = true; static override readonly id = "commissioning"; /** * Older versions stored a peer's session parameters under `sessionParameters`: the full effective set once a * session existed, otherwise only the DNS-SD intervals. The first becomes the reported set, the second the * advertised intervals. Values already under the new keys win. */ static override migrate(values: Val.Struct) { super.migrate(values); const { sessionParameters } = values; delete values.sessionParameters; if (!isObject(sessionParameters)) { return; } const { idleInterval, activeInterval, activeThreshold, ...rest } = sessionParameters; if (Object.keys(rest).length) { values.reportedSessionParameters ??= sessionParameters; return; } values.advertisedIntervals ??= SessionIntervals.advertisable({ idleInterval: storedMillis(idleInterval), activeInterval: storedMillis(activeInterval), activeThreshold: storedMillis(activeThreshold), }); } override initialize(options: { descriptor?: RemoteDescriptor }) { const descriptor = options?.descriptor; if (descriptor) { this.descriptor = descriptor; } if (this.state.discoveredAt === undefined) { this.state.discoveredAt = Time.nowMs; } // Defends against storage corruption / direct state manipulation; backfill from // OperationalCredentialsClient recovers on partsReady. const persistedFabricIndexOnPeer = this.state.fabricIndexOnPeer; if (persistedFabricIndexOnPeer !== undefined && !FabricIndex.isValid(persistedFabricIndexOnPeer)) { logger.warn( `Discarding invalid persisted fabricIndexOnPeer ${persistedFabricIndexOnPeer} for ${this.endpoint}`, ); this.state.fabricIndexOnPeer = undefined; } if (this.state.peerAddress !== undefined) { // And ensure we are coupled to the Peer instance this.#bindPeer(this.state.peerAddress); } const node = this.endpoint as ClientNode; this.reactTo(node.lifecycle.partsReady, this.#initializeNode); this.reactTo(this.events.peerAddress$Changed, this.#peerAddressChanged); this.reactTo(this.events.peerAddress$Changed, this.#deleteFormerPeer, { offline: true }); this.reactTo(this.events.addresses$Changed, this.#operationalAddressesChanged); this.reactTo(this.events.caseAuthenticatedTags$Changed, this.#catsChanged); } override [Symbol.asyncDispose]() { const peer = this.endpoint.env.maybeGet(Peer); if (peer) { this.#unbindPeer(peer.address); } } #findServerOtaProviderEndpoint() { const node = this.endpoint.owner as ServerNode; for (const endpoint of node.endpoints) { if (endpoint.behaviors.has(OtaSoftwareUpdateProviderServer)) { return endpoint; } } } commission(passcode: number | string): Promise; commission(options: CommissioningClient.CommissioningOptions): Promise; async commission(options: number | string | CommissioningClient.CommissioningOptions) { // Commissioning can only happen once const node = this.endpoint as ClientNode; if (this.state.peerAddress !== undefined) { throw new ImplementationError(`${node} is already commissioned`); } if (typeof options === "number") { options = { passcode: options }; } else if (typeof options === "string") { options = { pairingCode: options }; } const opts = CommissioningClient.PasscodeOptions(options) as CommissioningClient.PasscodeOptions; // Validate passcode let { passcode } = opts; if (!Number.isFinite(passcode)) { passcode = Number.parseInt(passcode as unknown as string); if (!Number.isFinite(passcode)) { throw new ImplementationError(`You must provide the numeric passcode to commission a node`); } } // Ensure the controller is initialized await node.owner.act(agent => agent.load(ControllerBehavior)); const controller = node.owner.agentFor(this.context).get(ControllerBehavior); // Get the fabric we will commission into const fabricAuthority = opts.fabricAuthority ?? this.env.get(FabricAuthority); let { fabric } = opts; if (fabric === undefined) { if (this.context.fabric === undefined) { const config = await node.owner?.act(agent => agent.get(ControllerBehavior).fabricAuthorityConfig); if (config === undefined) { throw new ImplementationError( `Cannot commission ${node} because no fabric was specified and the controller has no fabric configuration`, ); } fabric = await fabricAuthority.defaultFabric(config); } else { fabric = node.env.get(FabricManager).for(this.context.fabric); } } if (!fabricAuthority.hasControlOf(fabric)) { throw new ImplementationError( `Cannot commission ${node} fabric ${fabric.fabricIndex} because we do not control this fabric`, ); } const addresses = this.state.addresses; if (!addresses?.length) { throw new ImplementationError(`Cannot commission ${node} because the node has not been located`); } const identity = this.env.get(IdentityService); // Reservation is deferred to the post-PASE gate below, so fast-fail an explicit node ID that is already // taken (a commissioned peer or the controller's own identity) here instead of only after discovery+PASE. if ( opts.nodeId !== undefined && identity.peerAddressInUse({ fabricIndex: fabric.fabricIndex, nodeId: opts.nodeId }) ) { throw new IdentityConflictError( `Cannot assign NodeId ${opts.nodeId} on fabric ${fabric.fabricIndex}: the peer address is already in use`, ); } const commissioner = node.env.get(ControllerCommissioner); // Claim the node ID only once a candidate wins PASE. Parallel candidates share one supplied node ID, so // reserving before PASE would fail every loser with an identity conflict; only the winner reserves here. const raceGate = options.continueCommissioningAfterPase; let allocatedAddress: PeerAddress | undefined; const claimNodeIdAfterPase = async () => { if (raceGate !== undefined && !raceGate()) { return undefined; } allocatedAddress = await controller.allocatePeerAddress(fabric.fabricIndex, opts.nodeId); return allocatedAddress.nodeId; }; const commissioningOptions: LocatedNodeCommissioningOptions = { addresses: addresses.map(ServerAddress), fabric, nodeId: opts.nodeId, passcode, discoveryData: this.descriptor, commissioningFlowImpl: options.commissioningFlowImpl, onAttestationFailure: options.onAttestationFailure, abort: options.abort, claimNodeIdAfterPase, wifiNetwork: options.wifiNetwork, threadNetwork: options.threadNetwork, regulatoryLocation: options.regulatoryLocation, regulatoryCountryCode: options.regulatoryCountryCode, timeout: options.timeout, caseConnectionTiming: options.caseConnectionTiming ?? defaultCaseConnectionTiming, caseConnectionTimeout: options.caseConnectionTimeout, }; // Check if our server has an OTA Provider (later: and no custom one is provided) and register the location const otaProviderEndpoint = this.#findServerOtaProviderEndpoint(); if ( otaProviderEndpoint !== undefined && otaProviderEndpoint.stateOf(SoftwareUpdateManager).announceAsDefaultProvider ) { commissioningOptions.otaUpdateProviderLocation = { nodeId: fabric.rootNodeId, endpoint: otaProviderEndpoint.number, }; } if (options.finalizeCommissioning !== undefined) { commissioningOptions.finalizeCommissioning = options.finalizeCommissioning; } else if (this.finalizeCommissioning !== CommissioningClient.prototype.finalizeCommissioning) { commissioningOptions.finalizeCommissioning = this.finalizeCommissioning.bind(this); } try { const { address, fabricIndexOnPeer } = await commissioner.commission(commissioningOptions); this.state.peerAddress = address; this.state.commissionedAt = Time.nowMs; this.state.fabricIndexOnPeer = fabricIndexOnPeer; // Apply changes from the peer await this.#update(this.env.get(PeerSet).for(address)); } catch (e) { if (allocatedAddress !== undefined) { identity.releasePeerAddress(allocatedAddress); } throw e; } if (opts.caseAuthenticatedTags !== undefined) { this.state.caseAuthenticatedTags = opts.caseAuthenticatedTags; } const network = this.agent.get(NetworkClient); network.state.defaultSubscription = opts.defaultSubscription; // Nodes we commission are auto-subscribed by default, unless disabled explicitly network.state.autoSubscribe = opts.autoSubscribe !== false; network.state.autoStateInitialize = opts.autoStateInitialize; network.internal.isNewlyCommissioned = true; await this.context.transaction.commit(); logger.notice( "Commissioned", Diagnostic.strong(this.endpoint.id), "as", Diagnostic.strong(this.endpoint.identity), ); node.lifecycle.commissioned.emit(this.context); await node.start(); return node; } /** * Remove this node from the fabric. * * After removal the {@link ClientNode} remains intact. You can use {@link ClientNode#delete} to remove the node * permanently. * * Only legal if this node controls the peer's fabric. */ async decommission() { const { peerAddress } = this.state; if (peerAddress === undefined) { throw new ImplementationError("Cannot decommission node that is not commissioned"); } const formerAddress = ProtocolPeerAddress(peerAddress).toString(); // A peer commissioned without a structure read or subscription has no OperationalCredentials behavior to // send RemoveFabric through, and one read only partially may hold no fabric index; reading the attribute // installs the behavior and supplies the index const node = this.endpoint as ClientNode; if ( !node.behaviors.has(OperationalCredentialsClient) || (this.state.fabricIndexOnPeer === undefined && !node.maybeStateOf(OperationalCredentialsClient)?.currentFabricIndex) ) { const fabricFilter = node.env.get(ClientStructure).cacheFabricFiltered; for await (const _chunk of node.interaction.read( Read( { fabricFilter }, Read.Attribute({ endpoint: node, cluster: OperationalCredentials.Cluster, attributes: "currentFabricIndex", }), ), )); } const opcreds = this.agent.get(OperationalCredentialsClient); const fabricIndex = this.state.fabricIndexOnPeer ?? opcreds.state.currentFabricIndex; // Removing the fabric we communicate over destroys the session the NocResponse must travel on, so the response // is frequently lost. Treat the command as confirmed if it was delivered (transient peer error) or if a // matching leave event arrives. Arm the listener before sending so a fast leave cannot be missed. let leaveSeen = false; const leaveEvents = node.behaviors.has(BasicInformationClient) ? node.eventsOf(BasicInformationClient).leave : undefined; const onLeave = ({ fabricIndex: leftFabricIndex }: { fabricIndex: FabricIndex }) => { // Ignore leaves replayed during subscription establishment; they may be stale events from a prior // commissioning with the same identifier, matching the guard in Peers#onLeave. if (leftFabricIndex === fabricIndex && this.agent.get(NetworkClient).subscriptionActive) { leaveSeen = true; } }; leaveEvents?.on(onLeave); try { logger.debug(`Removing node ${formerAddress} by removing fabric ${fabricIndex} on the node`); // A non-Ok status is an explicit refusal and must never be overridden by a leave event. let nonOkFailure: MatterError | undefined; try { const result = await opcreds.removeFabric({ fabricIndex }); if (result.statusCode !== OperationalCredentials.NodeOperationalCertStatus.Ok) { nonOkFailure = new MatterError( `Removing node ${formerAddress} failed with status ${result.statusCode} "${result.debugText}".`, ); } } catch (error) { if ( leaveSeen || causedBy(error, PeerMessageMissingError, PeerLeftError, FabricRemovedError, PeerInitiatedCloseError) ) { logger.info( `Node ${formerAddress} removal confirmed without response`, `(${leaveSeen ? "leave event" : "session closed"})`, ); } else { throw error; } } if (nonOkFailure !== undefined) { throw nonOkFailure; } // Removal confirmed. Must run before commit unbinds Peer via peerAddress$Changed. try { await node.env.maybeGet(Peer)?.disconnect(new PeerLeftError()); } catch (error) { logger.warn(`Error force-closing sessions for ${formerAddress} after decommission:`, error); } this.state.peerAddress = undefined; this.state.commissionedAt = undefined; this.state.fabricIndexOnPeer = undefined; this.state.reportedSessionParameters = undefined; this.state.tcpUnsupported = undefined; await this.context.transaction.commit(); logger.info( "Decommissioned", Diagnostic.strong(this.endpoint.id), "formerly", Diagnostic.strong(formerAddress), ); } finally { leaveEvents?.off(onLeave); } } /** * Open a Basic Commissioning Window (uses the passcode originally printed on the device). * * This is an optional feature and not all devices support it; use {@link openEnhancedCommissioningWindow} unless * you specifically need the basic commissioning method. */ async openBasicCommissioningWindow(commissioningTimeout: Duration = Seconds(900)) { if (!this.endpoint.featuresOf(AdministratorCommissioningClient).basic) { throw new ImplementationError(`${this.endpoint} does not support the basic commissioning method`); } const adminCommissioning = this.agent.get(AdministratorCommissioningClient); await this.#revokeStaleCommissioningWindow(adminCommissioning); await adminCommissioning.openBasicCommissioningWindow({ commissioningTimeout: Seconds.of(commissioningTimeout), }); } /** * Open an Enhanced Commissioning Window using a freshly generated random passcode. * * The peer's BasicInformation must be seeded — its vendor/product IDs are encoded into the QR pairing code — so * this throws (before opening any window) if it is not. * * @returns the pairing codes and the values they encode. */ async openEnhancedCommissioningWindow( commissioningTimeout: Duration = Seconds(900), ): Promise { const basicInformation = this.endpoint.maybeStateOf(BasicInformationClient); if (basicInformation === undefined) { throw new ImplementationError( `${this.endpoint} must be seeded (BasicInformation) before opening an enhanced commissioning window; its vendor/product IDs are encoded in the QR pairing code`, ); } const adminCommissioning = this.agent.get(AdministratorCommissioningClient); await this.#revokeStaleCommissioningWindow(adminCommissioning); const timeoutSeconds = Seconds.of(commissioningTimeout); const crypto = this.env.get(Crypto); const discriminator = PaseClient.generateRandomDiscriminator(crypto); const passcode = PaseClient.generateRandomPasscode(crypto); const salt = crypto.randomBytes(32); const iterations = CRYPTO_PBKDF_ITERATIONS_MIN; const pakePasscodeVerifier = await PaseClient.generatePakePasscodeVerifier(crypto, passcode, { iterations, salt, }); await adminCommissioning.openCommissioningWindow({ commissioningTimeout: timeoutSeconds, pakePasscodeVerifier, salt, iterations, discriminator, }); const { vendorId, productId } = basicInformation; // TODO: If the timeout is shorter than 15 minutes, also encode it in the QR code's TLV data. const qrPairingCode = QrPairingCodeCodec.encode([ { version: 0, vendorId, productId, flowType: CommissioningFlowType.Standard, discriminator, passcode, discoveryCapabilities: DiscoveryCapabilitiesSchema.encode({ onIpNetwork: true }), }, ]); return { manualPairingCode: ManualPairingCodeCodec.encode({ discriminator, passcode, flowType: CommissioningFlowType.Standard, }), qrPairingCode, passcode, discriminator, vendorId, productId, commissioningTimeout: Seconds(timeoutSeconds), }; } /** Tolerates the device reporting that no window is currently open, so a stale window can be revoked blindly. */ async #revokeStaleCommissioningWindow(adminCommissioning: AdministratorCommissioningClient) { try { await adminCommissioning.revokeCommissioning(); } catch (error) { if ( !StatusResponseError.is(error, Status.Failure) || StatusResponseError.of(error)?.clusterCode !== AdministratorCommissioning.StatusCode.WindowNotOpen ) { throw error; } } } /** * Override to implement CASE commissioning yourself. * * If you override, matter.js commissions to the point where commissioning over PASE is complete. You must then * complete commissioning yourself by connecting to the device and invoking the "CommissioningComplete" command. */ protected async finalizeCommissioning( _address: ProtocolPeerAddress, _discoveryData?: DiscoveryData, _sessionParameters?: Partial, ) { throw new NotImplementedError(); } get descriptor() { return RemoteDescriptor.fromLongForm(this.state); } set descriptor(descriptor: RemoteDescriptor | undefined) { RemoteDescriptor.toLongForm(descriptor, this.state); } #initializeNode() { const endpoint = this.endpoint as ClientNode; this.#backfillFabricIndexOnPeer(); // If the synchronous backfill missed (OpCreds subscription still in-flight), self-heal on the next // currentFabricIndex update. Reactor is idempotent — once state is filled it short-circuits. if (this.state.fabricIndexOnPeer === undefined && this.endpoint.behaviors.has(OperationalCredentialsClient)) { this.reactTo( this.endpoint.eventsOf(OperationalCredentialsClient).currentFabricIndex$Changed, this.#backfillFabricIndexOnPeer, ); } endpoint.lifecycle.initialized.emit(this.state.peerAddress !== undefined); } /** Recover `state.fabricIndexOnPeer` from `OperationalCredentialsClient.currentFabricIndex` when missing. */ #backfillFabricIndexOnPeer() { if (this.state.peerAddress === undefined || this.state.fabricIndexOnPeer !== undefined) { return; } const value = this.endpoint.maybeStateOf(OperationalCredentialsClient)?.currentFabricIndex; if (value !== undefined && value !== FabricIndex.NO_FABRIC) { this.state.fabricIndexOnPeer = value; } } #operationalAddressesChanged(newAddresses: ServerAddress[] | undefined, oldAddresses: ServerAddress[] | undefined) { // Log when addresses change if (newAddresses === undefined) { logger.info("Operational address for", Diagnostic.strong(PeerAddress(this.state.peerAddress)), "cleared"); return; } const newAddressesStr = newAddresses ?.filter(a => ServerAddress.isIp(a)) .map(a => ServerAddress.urlFor(a)) .join(", "); if (oldAddresses === undefined) { logger.info( "Operational address for", Diagnostic.strong(PeerAddress(this.state.peerAddress)), "set to", Diagnostic.weak(newAddressesStr), ); return; } const oldAddressesStr = oldAddresses .filter(a => ServerAddress.isIp(a)) .map(a => ServerAddress.urlFor(a)) .join(", "); if (oldAddressesStr !== newAddressesStr) { logger.info( "Operational address changed for", Diagnostic.strong(PeerAddress(this.state.peerAddress)), "from", Diagnostic.weak(oldAddressesStr), "to", Diagnostic.weak(newAddressesStr), ); } } #peerAddressChanged(addr?: ProtocolPeerAddress, oldAddr?: ProtocolPeerAddress) { const node = this.endpoint as ClientNode; if (addr) { this.#bindPeer(addr); node.lifecycle.commissioned.emit(this.context); } else if (oldAddr) { this.#unbindPeer(oldAddr); node.lifecycle.decommissioned.emit(this.context); } } /** * Close and forget the protocol peer of an address the node no longer has. */ async #deleteFormerPeer(addr?: ProtocolPeerAddress, oldAddr?: ProtocolPeerAddress) { if (addr !== undefined || oldAddr === undefined || this.state.peerAddress !== undefined) { return; } const formerAddress = ProtocolPeerAddress(oldAddr); try { await this.env.maybeGet(PeerSet)?.get(formerAddress)?.delete(); } catch (error) { logger.warn(`Error removing peer ${formerAddress} after its address was cleared:`, error); } } #catsChanged(cats?: CaseAuthenticatedTag[]) { if (!this.state.peerAddress) { return; } const node = this.endpoint as ClientNode; if (!node.env.has(PeerSet)) { return; } const peer = node.env.get(PeerSet).for(this.state.peerAddress); if (!peer) { return; } peer.descriptor.caseAuthenticatedTags = cats; } /** * Couple my {@link ClientNode} with the equivalent {@link Peer}. */ #bindPeer(addr: PeerAddress) { const node = this.endpoint as ClientNode; let peer = node.env.maybeGet(Peer); if (peer) { if (PeerAddress.is(peer.address, addr) && node.env.get(PeerSet).has(peer)) { // Already bound and present in PeerSet return; } // Peer address changed or peer was removed from PeerSet; rebind this.#unbindPeer(peer.address); } const peers = node.env.get(PeerSet); peer = peers.addKnownPeer({ address: addr, operationalAddress: OperationalAddress.from(this.state.addresses?.find(a => ServerAddress.isIp(a))), discoveryData: RemoteDescriptor.fromLongForm(this.state), caseAuthenticatedTags: this.state.caseAuthenticatedTags, reportedSessionParameters: deepCopy(this.state.reportedSessionParameters), tcpUnsupported: this.state.tcpUnsupported, }); peer.interaction = node.interaction as ClientInteraction; peer.protocol = node.protocol; this.internal.peerObserver = peer.updated.use((this.internal.peerUpdated ??= this.callback(this.#update))); this.env.set(Peer, peer); } /** * Apply changes from the {@link Peer}. * * This persists information discovered via MDNS and the connection process. */ async #update(peer: Peer) { const { transaction } = this.context; await transaction.addResources(this); await transaction.begin(); if (peer.descriptor.reportedSessionParameters !== undefined) { this.state.reportedSessionParameters = peer.descriptor.reportedSessionParameters; } this.state.tcpUnsupported = peer.descriptor.tcpUnsupported; const { descriptor: { discoveryData, operationalAddress, caseAuthenticatedTags }, } = peer; RemoteDescriptor.toLongForm(discoveryData, this.state); if (operationalAddress) { // TODO - modify lower tiers to pass along full set of operational addresses this.state.addresses = [operationalAddress]; } this.state.caseAuthenticatedTags = caseAuthenticatedTags; } /** * Uncouple my {@link ClientNode} from a {@link Peer}. */ #unbindPeer(addr: PeerAddress) { const node = this.endpoint as ClientNode; const peer = node.env.maybeGet(Peer); if (!peer || !PeerAddress.is(peer.address, addr)) { return; } node.env.delete(Peer, peer); this.internal.peerObserver?.[Symbol.dispose](); this.internal.peerObserver = undefined; if (peer.interaction === node.interaction) { peer.interaction = undefined; } if (peer.protocol === node.protocol) { peer.protocol = undefined; } } } /** * Default timing overrides applied to the step-18 CASE reconnect unless the caller supplies * {@link CommissioningClient.BaseCommissioningOptions.caseConnectionTiming}. * * Values are tighter than the global peer timing defaults because the device is known to be freshly online: * - `delayBeforeNextAddress`: 15 s (vs. 45 s global default) * - `maxDelayBetweenInitialContactRetries`: 60 s (vs. 2 min global default) * - `delayAfterPeerError`: 2 min (vs. 5 min global default) */ const defaultCaseConnectionTiming: Partial = { delayBeforeNextAddress: Seconds(15), maxDelayBetweenInitialContactRetries: Seconds(60), delayAfterPeerError: Minutes(2), }; export namespace CommissioningClient { /** * The pairing codes of an open Enhanced Commissioning Window and the values they encode. * * @see {@link MatterSpecification.v161.Core} § 5.1 * @see {@link MatterSpecification.v161.Core} § 11.19.8.1 */ export interface EnhancedCommissioningWindow { manualPairingCode: string; qrPairingCode: string; passcode: number; /** The long (12-bit) discriminator the device advertises while the window is open. */ discriminator: number; vendorId: VendorId; productId: number; /** How long the window stays open, in whole seconds as sent to the device. */ commissioningTimeout: Duration; } /** * Concrete version of {@link ProtocolPeerAddress}. */ export class PeerAddress implements ProtocolPeerAddress { @field(fabricIdx, mandatory) fabricIndex: FabricIndex; @field(nodeId, mandatory) nodeId: NodeId; constructor(fabricIndex: FabricIndex, nodeId: NodeId) { this.fabricIndex = fabricIndex; this.nodeId = nodeId; } } /** * Supported transport flags. */ @datatype(map16) export class SupportedTransports implements Partial { @field(uint16.extend({ constraint: "1" })) tcpClient?: boolean; @field(uint16.extend({ constraint: "2" })) tcpServer?: boolean; } /** * Session intervals a node advertises via DNS-SD (SII, SAI and SAT). * * @see {@link MatterSpecification.v161.Core} § 4.3.4 */ export class AdvertisedIntervals { @field(1, duration.extend({ constraint: "max 3600000" })) idleInterval?: Duration; @field(2, duration.extend({ constraint: "max 3600000" })) activeInterval?: Duration; @field(3, duration.extend({ constraint: "max 65535" })) activeThreshold?: Duration; } /** * Concrete version of {@link ProtocolSessionParameters}. */ export class SessionParameters implements Partial { // DNS-SD advertisements cap SII/SAI at one hour, but the session-parameter struct allows the full uint32 range. @field(1, duration.extend({ constraint: "max 4294967295" })) idleInterval?: Duration; @field(2, duration.extend({ constraint: "max 4294967295" })) activeInterval?: Duration; @field(3, duration.extend({ constraint: "max 65535" })) activeThreshold?: Duration; @field(4, uint32) dataModelRevision?: number; @field(5, uint16) interactionModelRevision?: number; @field(6, uint32) specificationVersion?: number; @field(7, uint16) maxPathsPerInvoke?: number; @field(8, SupportedTransports) supportedTransports?: SupportedTransports; @field(9, uint32) maxTcpMessageSize?: number; } /** * The network address of a node. */ export class NetworkAddress { @field(string, mandatory) type!: "udp" | "tcp" | "ble"; @field(string) ip?: string; @field(uint16) port?: number; @field(string) peripheralAddress?: string; @field(uint32) ttl?: Duration | undefined; @field(systimeMs) discoveredAt?: Timestamp | undefined; constructor(address: NetworkAddress) { this.type = address.type; if (address.ip !== undefined) { this.ip = address.ip; } if (address.port !== undefined) { this.port = address.port; } if (address.peripheralAddress !== undefined) { this.peripheralAddress = address.peripheralAddress; } if (address.ttl !== undefined) { this.ttl = address.ttl; } if (address.discoveredAt !== undefined) { this.discoveredAt = address.discoveredAt; } } } export class Internal { peerObserver?: Disposable; peerUpdated?: (peer: Peer) => void; } export class State { /** * Fabric index and node ID for paired peers. If this is undefined the node is uncommissioned. */ @field(PeerAddress, nonvolatile) peerAddress?: PeerAddress; /** * Case Authenticated Tags (CATs) * * See {@link PeerDescriptor} */ @field(listOf(uint32), nonvolatile) caseAuthenticatedTags?: readonly CaseAuthenticatedTag[]; /** * Known network addresses for the device. If this is undefined, the node has not been located on any network * interface. */ @field(listOf(NetworkAddress), nonvolatile) addresses?: ServerAddress[]; /** * The host named by the SRV record of the device's commissionable advertisement, as the * responder wrote it. * * Operational discovery does not report a host, so this stays what commissioning found while * {@link addresses} goes on being refreshed. * * @see {@link MatterSpecification.v161.Core} § 4.3.1 */ @field(string, nonvolatile) hostname?: string; /** * Time at which the device was discovered. */ @field(systimeMs, nonvolatile) discoveredAt?: Timestamp; /** * Time at which we discovered the device's current operational addresses. */ @field(systimeMs) onlineAt?: Timestamp; /** * Time at which we concluded the device's current operational address is unreachable. */ @field(systimeMs) offlineAt?: Timestamp; /** * Time at which the device was commissioned. */ @field(systimeMs, nonvolatile) commissionedAt?: Timestamp; /** * Fabric index the peer assigned to our identity (from the AddNoc response). Substituted for the * {@link FabricIndex.OMIT_FABRIC} sentinel on fabric-scoped struct writes so the local cache mirrors * the value the peer will store. * * Distinct from {@link peerAddress.fabricIndex} which is our local fabric index. */ @field(fabricIdx, nonvolatile) fabricIndexOnPeer?: FabricIndex; /** * The TTL of the discovery record if applicable (in seconds). */ @field(duration, nonvolatile) ttl?: Duration; /** * The canonical global ID of the device. */ @field(string, nonvolatile) deviceIdentifier?: string; /** * The device's long discriminator. */ @field(uint16, nonvolatile) discriminator?: number; /** * The last know commissioning mode of the device. */ @field(uint8, nonvolatile) commissioningMode?: CommissioningMode; /** * Vendor. */ @field(vendorId, nonvolatile) vendorId?: VendorId; /** * Product. */ @field(uint16, nonvolatile) productId?: number; /** * Advertised device type. */ @field(devtypeId, nonvolatile) deviceType?: DeviceTypeId; /** * The advertised device name specified by the user. */ @field(string, nonvolatile) deviceName?: string; /** * An optional manufacturer-specific unique rotating ID for uniquely identifying the device. */ @field(string, nonvolatile) rotatingIdentifier?: string; /** * A bitmap indicating how to transition the device to commissioning mode from its current state. */ @field(uint32, nonvolatile) pairingHint?: number; /** * Textual pairing instructions associated with pairing hint. */ @field(string, nonvolatile) pairingInstructions?: string; /** * The session parameters the remote node reported in its most recent session. */ @field(SessionParameters, nonvolatile) reportedSessionParameters?: SessionParameters; /** * The session intervals the remote node advertises via DNS-SD. */ @field(AdvertisedIntervals, nonvolatile) advertisedIntervals?: AdvertisedIntervals; /** * Set when a TCP session to the remote node reported no TCP server support; a later session reporting it clears * the flag. */ @field(bool, nonvolatile) tcpUnsupported?: boolean; /** * TCP support bitmap. */ @field(uint8, nonvolatile) tcpSupport?: number; /** * Indicates whether node is ICD with a slow (15 s+) polling interval. */ @field(bool, nonvolatile) longIdleTimeOperatingMode?: boolean; } export class Events extends BaseEvents { peerAddress$Changed = new Observable< [value: ProtocolPeerAddress | undefined, oldValue: ProtocolPeerAddress | undefined] >(); addresses$Changed = new Observable< [value: ServerAddress[] | undefined, oldValue: ServerAddress[] | undefined] >(); caseAuthenticatedTags$Changed = new Observable< [value: CaseAuthenticatedTag[] | undefined, oldValue: CaseAuthenticatedTag[] | undefined] >(); } /** * Options that control commissioning. */ export interface BaseCommissioningOptions { /** * The ID to assign the node during commissioning. By default the node receives the next available ID. */ nodeId?: NodeId; /** * The fabric the node joins upon commissioning. Defaults to the default fabric of the assigned * {@link FabricAuthority}. */ fabric?: Fabric; /** * The authority controlling the commissioning fabric. Defaults to the {@link FabricAuthority} of the local * environment. */ fabricAuthority?: FabricAuthority; /** * Custom commissioning flow implementation to use instead of the default. */ commissioningFlowImpl?: ClassExtends; /** * Abort signal for cancellation. With {@link ClientNode#commission} it cancels the PASE attempt. With * `peers.commission()` it cancels the whole discovery as `Discovery.Options.abort` describes. */ abort?: AbortSignal; /** * Called immediately after PASE is established, before the main commissioning flow begins. * * Return `true` to proceed with commissioning (this candidate won the parallel race). * Return `false` to abort cleanly — the PASE session is closed and commissioning stops. * * When omitted, commissioning always proceeds. */ continueCommissioningAfterPase?: () => boolean; /** * Overall wall-clock budget for PASE establishment. * Defaults to 30 seconds. */ timeout?: Duration; /** * The initial read/subscription used to populate node data. * * By default, matter.js reads all attributes on the node. This allows us to efficiently initialize the complete * node structure. * * If you only require a subset of attributes you can replace this with a more discriminative read. For * example, if you are only interested in interacting with the root endpoint and the On/Off cluster on other * endpoints, you could do: * * ```js * { * defaultSubscription: Read( * Read.Attribute({ endpoint: 0 }), * Read.Attribute({ cluster: OnOffCluster }) * ) * } * ``` * * Note that certain clusters like Descriptor and Basic Information contain critical operational data. If your * read omits them then the node will only be partially functional once initialized. A built read is sent as * given; see {@link NetworkClient.State.defaultSubscription}. */ defaultSubscription?: Subscribe; /** * By default, nodes we commission are automatically subscribed to using the {@link defaultSubscription} (or a * full wildcard subscription if that is undefined). * * Matter.js will not subscribe automatically if set to false. */ autoSubscribe?: boolean; /** * By default a newly-commissioned node performs a one-time read of its structure so its state is available * immediately, even when {@link autoSubscribe} is false. Set to false to skip this read and leave the node * uninitialized until a later subscription or read. */ autoStateInitialize?: boolean; /** * Case Authenticated Tags (CATs) */ caseAuthenticatedTags?: CaseAuthenticatedTag[]; /** * Controls behavior when device attestation produces findings. * See {@link ControllerCommissioningFlowOptions.onAttestationFailure} for details. */ onAttestationFailure?: DeviceAttestationValidator.OnAttestationFailure; /** * WiFi network credentials to configure on the device during commissioning. Required if the device connects * to the network over WiFi and doesn't already have credentials configured. */ wifiNetwork?: ControllerCommissioningFlowOptions["wifiNetwork"]; /** * Thread network credentials to configure on the device during commissioning. Required if the device connects * to the network over Thread and doesn't already have credentials configured. */ threadNetwork?: ControllerCommissioningFlowOptions["threadNetwork"]; /** * The regulatory location (indoor or outdoor) where the device is used. * Defaults to `Indoor` if not provided. */ regulatoryLocation?: ControllerCommissioningFlowOptions["regulatoryLocation"]; /** * The two-character country code where the device is deployed (e.g. "DE", "US"). * Defaults to "XX" (unspecified). */ regulatoryCountryCode?: ControllerCommissioningFlowOptions["regulatoryCountryCode"]; /** * Override the final operational step of commissioning. * * The flow runs PASE, arm-failsafe, CSR and AddNOC as usual, then invokes this hook **instead of** connecting * to the device operationally and sending "CommissioningComplete" itself, and awaits it. The hook owns the * finalization and must drive it to completion before resolving: resolve on success, throw on failure. * `commission()` resolves or rejects with this hook's outcome — a throw rolls the commissioning back. * * For a delegated/split flow, hand `address.nodeId`, `discoveryData` and `sessionParameters` (those the device * reported over PASE) to the controller that will finish (typically a separate, network-side controller sharing * this fabric) and **await** its `serverNode.peers.completeCommissioning(nodeId, discoveryData, * sessionParameters)` from within this hook. Do not return before that completes: the PASE session is held * open across the hook and the device's failsafe stays armed until "CommissioningComplete" arrives, so * returning early leaves the device mid-commission and it reverts, discarding the freshly issued NOC. See * docs/MIGRATION_CONTROLLER_018.md for the full recipe. */ finalizeCommissioning?: ( address: ProtocolPeerAddress, discoveryData?: DiscoveryData, sessionParameters?: Partial, ) => Promise; /** * Timing overrides for the step-18 CASE reconnect. * * After commissioning the commissioner establishes the first operational CASE session. The device is freshly * online at that point so tighter timing is appropriate. Any fields provided here are merged on top of the * global peer timing defaults for that single connection only. * * Defaults to `{ delayBeforeNextAddress: Seconds(15), maxDelayBetweenInitialContactRetries: Seconds(60), delayAfterPeerError: Minutes(2) }`. */ caseConnectionTiming?: Partial; /** * Wall-clock budget for the step-18 CASE reconnect, across every candidate address and retry. * * Defaults to `DEFAULT_CASE_CONNECTION_TIMEOUT`, which leaves room for two server-side retry windows. Lower it * to bound commissioning to a single handshake attempt, so a handshake the device fails is a commissioning * failure rather than something a retry can recover. * * On an IP transport, raising it past the failsafe armed for this step (up to 5 minutes for the reconnect, * clamped by the device's own `MaxCumulativeFailsafeSeconds`) has the device roll `addNOC` back mid-connect, so a * longer budget cannot succeed; the concurrent BLE flow keeps re-arming instead. A non-finite budget is * rejected. * * Expiry fails commissioning, which deletes the peer and so aborts the connection it was still attempting. The * rejection therefore reports a budget that ran out, not what the device answered. No effect where * {@link finalizeCommissioning} owns the operational step, nor on the connection * `peers.completeCommissioning()` makes. */ caseConnectionTimeout?: Duration; } export interface PasscodeOptions extends BaseCommissioningOptions { /** * The device's passcode. */ passcode: number; /** * The device's long discriminator. */ discriminator?: number; /** * The vendor the onboarding payload names, if it names one. * * A commissionable advertisement may state its own vendor and product (§ 4.3.1's `VP` record). * Where both the payload and the advertisement name one, a device that disagrees is not the * device the payload describes and is passed over — otherwise a code for one device silently * onboards another sharing its discriminator. */ vendorId?: VendorId; /** The product the onboarding payload names, matched as {@link vendorId} is. */ productId?: number; } export interface PairingCodeOptions extends BaseCommissioningOptions { /** * The device's pairing code. */ pairingCode: string; } export type CommissioningOptions = PasscodeOptions | PairingCodeOptions; export function PasscodeOptions(options: T) { let opts: T & PasscodeOptions; if ("pairingCode" in options) { const decoded = ManualPairingCodeCodec.decode(options.pairingCode); opts = { ...options, ...decoded, }; } else { opts = options as T & PasscodeOptions; } let { passcode } = opts; if (typeof passcode !== "number" || !Number.isFinite(passcode)) { passcode = Number.parseInt(passcode as unknown as string); if (!Number.isFinite(passcode)) { throw new ImplementationError("You must provide a pairing code or passcode to pair a node"); } } return opts; } }