/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { CommissioningClient } from "#behavior/system/commissioning/CommissioningClient.js"; import type { ClientNode } from "#node/ClientNode.js"; import type { ServerNode } from "#node/ServerNode.js"; import { Logger, Minutes } from "@matter/general"; import { VendorId } from "@matter/types"; import { Discovery } from "./Discovery.js"; import { ParallelPaseDiscovery } from "./ParallelPaseDiscovery.js"; const logger = Logger.get("CommissioningDiscovery"); /** * Discovers and commissions nodes. All discovered candidates are commissioned in parallel; the first to establish * PASE wins. Discovery is stopped at PASE time and the abort signal fires to cancel remaining in-flight PASE * attempts. Any candidate that establishes PASE after the winner cleans up its session without proceeding to * commissioning. {@link onComplete} awaits the winner's commissioning to finish before returning. */ export class CommissioningDiscovery extends ParallelPaseDiscovery { #options: CommissioningDiscovery.Options; /** * Resolved once: {@link CommissioningClient.PasscodeOptions} decodes a pairing code and validates * its check digit, which is not work to repeat for every candidate discovery turns up. */ #identity: CommissioningDiscovery.Identity; constructor(owner: ServerNode, options: CommissioningDiscovery.Options) { const opts = CommissioningClient.PasscodeOptions(options); const { discriminator } = opts; if (discriminator !== undefined) { options = { ...options, longDiscriminator: discriminator }; } // Default discovery timeout matches the spec minimum commissioning window (3 minutes). // Devices that need factory reset, BLE→WiFi transition, or Thread joining can take 2+ minutes // before they start mDNS advertising. if (options.timeout === undefined) { options = { ...options, timeout: Minutes(3) }; } super(owner, options); this.#options = options; this.#identity = { vendorId: opts.vendorId, productId: opts.productId }; } protected override get cleanupLabel() { return "Commissioning candidate cleanup"; } protected override get failureMessage() { return "No device could be commissioned"; } protected override onDiscovered(node: ClientNode) { if (this.paseWon) return; if (!this.#namesThisDevice(node)) { return; } this.registerAttempt( winOnPase => node.commission({ ...this.#options, abort: this.abortSignal, continueCommissioningAfterPase: winOnPase, }), () => node, ); } #namesThisDevice(node: ClientNode) { const mismatch = CommissioningDiscovery.identityMismatch(this.#identity, node.state.commissioning); if (mismatch !== undefined) { logger.notice( `Passing over ${node}: it advertises ${mismatch.facet} ${mismatch.advertised} where the onboarding payload names ${mismatch.payload}`, ); return false; } return true; } } export namespace CommissioningDiscovery { export type Options = Discovery.InstanceOptions & CommissioningClient.CommissioningOptions; /** What an onboarding payload and a commissionable advertisement each say about a device's identity. */ export interface Identity { vendorId?: VendorId; productId?: number; } /** The one identifier on which an advertisement contradicts the payload. */ export interface IdentityMismatch { facet: "vendor" | "product"; payload: number; advertised: number; } /** * Why `advertised` is not the device `payload` names, or `undefined` if it may be. * * Discovery browses one DNS-SD sub-service, so a discriminator is all it can narrow by; the vendor * and product a payload names are checked against what the device advertises (§ 4.3.1's `VP` * record) once it is found. Either side may say nothing — a manual pairing code carries no identity * in its 11-digit form and the record is optional — and absence never rejects, so this only ever * refuses a device that positively advertises something else. * * Mirrors CHIP's `SetUpCodePairer::NodeMatchesCurrentFilter`. Both codecs already report § 2.5.2 / * § 2.5.3's "unspecified" as an absent identifier, so nothing here has to know that it is 0 on the * wire. */ export function identityMismatch(payload: Identity, advertised: Identity): IdentityMismatch | undefined { if ( payload.vendorId !== undefined && advertised.vendorId !== undefined && payload.vendorId !== advertised.vendorId ) { return { facet: "vendor", payload: payload.vendorId, advertised: advertised.vendorId }; } if ( payload.productId !== undefined && advertised.productId !== undefined && payload.productId !== advertised.productId ) { return { facet: "product", payload: payload.productId, advertised: advertised.productId }; } return undefined; } }