/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { SubscriptionsServer } from "#behavior/system/subscriptions/SubscriptionsServer.js"; import { Endpoint } from "#endpoint/Endpoint.js"; import { EndpointType } from "#endpoint/type/EndpointType.js"; import type { Node } from "#node/Node.js"; import { NodeLifecycle } from "#node/NodeLifecycle.js"; import type { ServerNode } from "#node/ServerNode.js"; import { AsyncObservable, Bytes, Crypto, Diagnostic, EventEmitter, ImplementationError, Lifecycle, Logger, MatterFlowError, Mutex, MutexClosedError, Observable, } from "@matter/general"; import { DatatypeModel, FieldElement } from "@matter/model"; import { BleAdvertiser, CommissioningConfigProvider, DeviceAdvertiser, DeviceCommissioner, ExposedFabricInformation, FabricManager, FailsafeContext, MdnsAdvertiser, PaseClient, Val, } from "@matter/protocol"; import { CommissioningFlowType, CommissioningOptions, DiscoveryCapabilitiesSchema, FabricIndex, ManualPairingCodeCodec, QrCode, QrPairingCodeCodec, } from "@matter/types"; import { BasicInformationBehavior } from "../../../behaviors/basic-information/BasicInformationBehavior.js"; import { OperationalCredentialsBehavior } from "../../../behaviors/operational-credentials/OperationalCredentialsBehavior.js"; import { Behavior } from "../../Behavior.js"; import { ActionContext } from "../../context/ActionContext.js"; import { NetworkServer } from "../network/NetworkServer.js"; import { ProductDescriptionServer } from "../product-description/ProductDescriptionServer.js"; import { SessionsBehavior } from "../sessions/SessionsBehavior.js"; const logger = Logger.get("Commissioning"); /** * Server behavior related to commissioning. * * Updates node state based on commissioning status. */ export class CommissioningServer extends Behavior { static override readonly id = "commissioning"; declare readonly state: CommissioningServer.State; declare readonly events: CommissioningServer.Events; declare internal: CommissioningServer.Internal; static override early = true; override initialize() { // This provides a name for the mutex error handler this.internal.name = this.toString(); if (this.state.passcode === -1) { this.state.passcode = PaseClient.generateRandomPasscode(this.env.get(Crypto)); } else if (CommissioningOptions.FORBIDDEN_PASSCODES.includes(this.state.passcode)) { throw new ImplementationError(`Passcode ${this.state.passcode} is not allowed`); } if (this.state.discriminator === -1) { this.state.discriminator = PaseClient.generateRandomDiscriminator(this.env.get(Crypto)); } this.reactTo((this.endpoint as Node).lifecycle.partsReady, this.#initializeNode); // Ensure the environment conveys commissioning configuration to the DeviceCommissioner if (!this.env.has(CommissioningConfigProvider)) { // Configure the DeviceCommissioner this.env.set( CommissioningConfigProvider, new CommissioningServerConfigProvider(this.endpoint as ServerNode), ); } this.reactTo((this.endpoint as Node).lifecycle.online, this.#enterOnlineMode); this.reactTo((this.endpoint as Node).lifecycle.goingOffline, this.#enterOfflineMode); } override async [Symbol.asyncDispose]() { // Ensure dependencies are cleaned up this.#enterOfflineMode(); // Wait for cleanup to finish await this.internal.mutex; // Lives as long as the behavior: initialize() does not install it again when the node restarts this.env.delete(CommissioningConfigProvider); } /** * Brings the commissioned state in line with the node's fabrics after `fabricAction` and emits * {@link CommissioningServer.Events.fabricsChanged}. */ async handleFabricChange(fabricIndex: FabricIndex, fabricAction: CommissioningServer.FabricAction) { // The fail-safe rolls back only the fabric its AddNOC or UpdateNOC changed, so only a change of that fabric waits // for the fail-safe to end. A deletion is final, whoever holds the fail-safe if (fabricAction !== "deleted" && this.env.has(FailsafeContext)) { const failsafe = this.env.get(FailsafeContext); if (failsafe.construction.status !== Lifecycle.Status.Destroyed && failsafe.fabricIndex === fabricIndex) { this.#monitorFailsafe(failsafe); return; } } // Another transaction may hold this state; a synchronous write would throw instead of waiting await this.context.transaction.lock(this); const doFactoryReset = this.#syncCommissioned(); this.events.fabricsChanged.emit(fabricIndex, fabricAction); if (doFactoryReset) { this.internal.resetPending = true; this.reactTo(this.agent.get(SessionsBehavior).events.closed, this.#resetWhenIdle); this.#resetWhenIdle(); } } /** * Brings the commissioned state in line with the fabrics the node holds, emitting the transition. * * @returns whether the node just lost its last fabric and must be reset. */ #syncCommissioned() { const commissioned = !!this.env.get(FabricManager).fabrics.length; if (commissioned === this.state.commissioned) { return false; } this.state.commissioned = commissioned; const lifecycle = this.endpoint.lifecycle as NodeLifecycle; if (commissioned) { this.events.commissioned.emit(this.context); lifecycle.commissioned.emit(this.context); return false; } this.events.decommissioned.emit(this.context); lifecycle.decommissioned.emit(this.context); return true; } get #activeFailsafe() { if (!this.env.has(FailsafeContext)) { return; } const failsafe = this.env.get(FailsafeContext); return failsafe.construction.status === Lifecycle.Status.Destroyed ? undefined : failsafe; } /** * Factory resets the node after it lost its last fabric, once no fail-safe is armed and no session remains. A * fabric that exists once the fail-safe ended, such as one a commissioning completed, cancels the reset. */ #resetWhenIdle() { if (!this.internal.resetPending) { return; } // A fabric added under the fail-safe is still in the fabric list until the fail-safe commits or rolls it back const failsafe = this.#activeFailsafe; if (failsafe !== undefined) { this.reactTo(failsafe.construction.change, this.#resetWhenIdle); return; } if (this.env.get(FabricManager).fabrics.length) { this.#endPendingReset(); return; } if (Object.keys(this.agent.get(SessionsBehavior).state.sessions).length > 0) { return; } this.#endPendingReset(); this.env.runtime.add((this.endpoint as ServerNode).erase().catch(e => MutexClosedError.accept(e))); } #endPendingReset() { this.internal.resetPending = false; this.stopReacting({ reactor: this.#resetWhenIdle }).catch(error => logger.warn("Failed to stop waiting for the factory reset", error), ); } #monitorFailsafe(failsafe: FailsafeContext) { if (this.internal.monitoredFailsafe) { return; } const listener = (this.internal.failsafeListener ??= this.callback(this.#failsafeChanged)); this.internal.monitoredFailsafe = failsafe; failsafe.construction.change.on(listener); } /** Updates the commissioning status once the monitored failsafe ends */ async #failsafeChanged(status: Lifecycle.Status) { const failsafe = this.internal.monitoredFailsafe; if (status !== Lifecycle.Status.Destroyed || failsafe === undefined) { return; } this.#stopMonitoringFailsafe(); if (failsafe.fabricIndex !== undefined) { await this.handleFabricChange(failsafe.fabricIndex, failsafe.forUpdateNoc ? "updated" : "added"); } } #stopMonitoringFailsafe() { const { monitoredFailsafe, failsafeListener } = this.internal; if (monitoredFailsafe !== undefined && failsafeListener !== undefined) { monitoredFailsafe.construction.change.off(failsafeListener); } this.internal.monitoredFailsafe = undefined; } async #enterOnlineMode() { const fabrics = this.env.get(FabricManager); this.reactTo(fabrics.events.added, this.enterOperationalMode); // A fabric added after initialization but before the node went online (a controller creating its own before // start) is not seen by the reactor above, which only exists from here on const lifecycle = this.endpoint.lifecycle as NodeLifecycle; if (!lifecycle.isCommissioned && fabrics.length) { // Commit before the awaits below so the commissioning state is not held while the node restores // subscriptions and advertises await this.context.transaction.lock(this); this.#syncCommissioned(); await this.context.transaction.commit(); } // If already commissioned, trigger operational announcement if (lifecycle.isCommissioned) { // Restore subscriptions if we have some persisted await this.endpoint.act(agent => agent.get(SubscriptionsServer).reestablishFormerSubscriptions()); this.enterOperationalMode(); return; } // Skip auto commissioning if disabled if (this.state.enabled === false) { logger.debug( Diagnostic.strong(this.endpoint.toString()), "does not advertise as commissionable: commissioning is disabled", ); return; } // ...or if node is commissioned if (fabrics.length) { return; } // ...or the node has no advertisable device type if (!this.#hasAdvertisableDeviceType) { return; } // Advertise as commissionable await this.enterCommissionableMode(); } #enterOfflineMode() { this.internal.mutex.run(async () => { await this.env.close(DeviceCommissioner); this.#stopMonitoringFailsafe(); await this.env.close(FailsafeContext); }); } /** * An uncommissioned node is not yet associated with fabrics. It cannot be used until commissioned by a controller. * * The server normally invokes this method when the node starts and is not yet commissioned. You can disable by * setting {@link CommissioningServer.State#enabled} to false. Then you must invoke yourself. * * By default the window closes after 48 hours if the node is not commissioned and after 15 minutes if it is. * Invoking this again restarts the window. A window an Administrator opens by command replaces this one; while * such a window is open this throws {@link MatterFlowError}. * * @see {@link MatterSpecification.v161.Core} § 5.4.2.3 */ async enterCommissionableMode() { if (!this.#hasAdvertisableDeviceType) { throw new ImplementationError( `Node ${this.endpoint} has no endpoints with advertisable device types; you must add an endpoint or set the device type`, ); } await this.env.get(DeviceCommissioner).allowBasicCommissioning(); this.initiateCommissioning(); } /** * The server invokes this method when the node starts and is already commissioned, or immediately after * commissioning. */ protected enterOperationalMode() { if (!(this.endpoint.lifecycle as NodeLifecycle).isOnline) { throw new ImplementationError("Cannot advertise offline server"); } this.env.get(DeviceAdvertiser).enterOperationalMode(); } /** * Display instructions on commissioning the device. * * The default implementation logs the QR code and credentials. */ protected initiateCommissioning() { const { passcode, discriminator } = this.state; const { qrPairingCode, manualPairingCode } = this.state.pairingCodes; logger.notice( Diagnostic.strong(this.endpoint.toString()), "is uncommissioned", Diagnostic.dict({ passcode, discriminator, "manual pairing code": manualPairingCode, }), Diagnostic.list([ QrCode.get(qrPairingCode).trim(), `QR code URL: https://project-chip.github.io/connectedhomeip/qrcode.html?data=${qrPairingCode}\n`, ]), ); } /** * Define logical schema to make passcode and discriminator persistent. */ static override readonly schema = new DatatypeModel({ name: "CommissioningState", type: "struct", children: [ FieldElement({ name: "passcode", type: "uint32", quality: "N" }), FieldElement({ name: "discriminator", type: "uint16", quality: "N" }), ], }); #initializeNode() { this.state.commissioned = !!this.agent.get(OperationalCredentialsBehavior).state.commissionedFabrics; (this.endpoint.lifecycle as NodeLifecycle).initialized.emit(this.state.commissioned); } get #hasAdvertisableDeviceType() { return this.agent.get(ProductDescriptionServer).state.deviceType !== EndpointType.UNKNOWN_DEVICE_TYPE; } } export namespace CommissioningServer { export interface PairingCodes { manualPairingCode: string; qrPairingCode: string; } export class Internal { /** The failsafe whose end updates the commissioning status */ monitoredFailsafe?: FailsafeContext = undefined; failsafeListener?: (status: Lifecycle.Status) => void = undefined; resetPending = false; /** * We use this to synchronize internal state transitions that would otherwise have race conditions due to the * large number of asynchronous calls. */ mutex = new Mutex({ toString: () => this.name, }); /** * Passed to the mutex for error handling purposes */ name = "?"; } export class State { enabled?: boolean; commissioned = false; fabrics: Record = {}; passcode = -1; discriminator = -1; flowType = CommissioningFlowType.Standard; additionalBleAdvertisementData?: Bytes = undefined; pairingCodes = {} as PairingCodes; mdns?: MdnsAdvertiser.Options; ble?: BleAdvertiser.Options; [Val.properties](endpoint: Endpoint) { const comm = this; return { get pairingCodes() { // passcode/discriminator default to -1 until initialize() generates them if (comm.passcode < 0 || comm.discriminator < 0) { throw new ImplementationError("pairingCodes read before commissioning was initialized"); } const bi = endpoint.stateOf(BasicInformationBehavior); const net = endpoint.stateOf(NetworkServer); const qrPairingCode = QrPairingCodeCodec.encode([ { version: 0, vendorId: bi.vendorId, productId: bi.productId, flowType: comm.flowType, discriminator: comm.discriminator, passcode: comm.passcode, discoveryCapabilities: DiscoveryCapabilitiesSchema.encode(net.discoveryCapabilities), }, ]); return { manualPairingCode: ManualPairingCodeCodec.encode({ discriminator: comm.discriminator, passcode: comm.passcode, flowType: comm.flowType, // § 5.1.4.1.2: a non-standard flow SHALL carry VID/PID in the manual code. ...(comm.flowType !== CommissioningFlowType.Standard ? { vendorId: bi.vendorId, productId: bi.productId } : {}), }), qrPairingCode, }; }, get fabrics() { const exposedFabrics: Record = {}; endpoint.env .get(FabricManager) .fabrics.forEach( ({ fabricIndex, externalInformation }) => (exposedFabrics[fabricIndex] = externalInformation), ); return exposedFabrics; }, }; } } export class Events extends EventEmitter { commissioned = Observable<[context: ActionContext]>(); decommissioned = Observable<[context: ActionContext]>(); fabricsChanged = Observable<[fabricIndex: FabricIndex, action: FabricAction]>(); enabled$Changed = AsyncObservable<[context: ActionContext]>(); } export type FabricAction = "added" | "deleted" | "updated"; } /** * Conveys configuration information to {@link DeviceCommissioner}. */ class CommissioningServerConfigProvider extends CommissioningConfigProvider { #node: ServerNode; constructor(node: ServerNode) { super(); this.#node = node; } override get values() { const { commissioning, productDescription, network } = this.#node.state; const config = { ...commissioning, productDescription, ble: !!network.ble, }; return config as CommissioningOptions.Configuration; } }