/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Behavior } from "#behavior/Behavior.js"; import { BasicInformationBehavior } from "#behaviors/basic-information"; import { IdentityConflictError } from "#endpoint/errors.js"; import { Node } from "#node/Node.js"; import { IdentityService } from "#node/server/IdentityService.js"; import { Crypto, DnsRecordType, ImplementationError, Logger, SharedEnvironmentServices, TransportSet, } from "@matter/general"; import { MatterModel } from "@matter/model"; import { Ble, CommissionableMdnsScanner, Fabric, FabricAuthority, FabricAuthorityConfiguration, FabricManager, MdnsService, PeerAddress, PeerSet, ScannerSet, getFabricQname, } from "@matter/protocol"; import { CaseAuthenticatedTag, FabricId, FabricIndex, NodeId } from "@matter/types"; import { CommissioningServer } from "../commissioning/CommissioningServer.js"; import { NetworkServer } from "../network/NetworkServer.js"; import { ActiveDiscoveries } from "./discovery/ActiveDiscoveries.js"; import type { Discovery } from "./discovery/Discovery.js"; const logger = Logger.get("ControllerBehavior"); /** * Node controller functionality. * * For our purposes, a "controller" is a node that supports commissioning of remote devices. * * This class initializes components required for controller usage and tracks active discoveries. Discovery logic * resides in {@link Discovery} and commissioning logic in {@link CommissioningClient}. */ export class ControllerBehavior extends Behavior { static override readonly id = "controller"; declare internal: ControllerBehavior.Internal; declare readonly state: ControllerBehavior.State; override async initialize() { if (this.state.adminFabricLabel === undefined || this.state.adminFabricLabel === "") { throw new ImplementationError("adminFabricLabel must be set for ControllerBehavior"); } const node = Node.forEndpoint(this.endpoint); // Publish the controller's model so auto-created peer nodes inherit it via the environment if (this.state.matter !== undefined) { node.env.set(MatterModel, this.state.matter); } // Configure discovery transports if (this.state.ip === undefined) { this.state.ip = true; } if (this.state.ip !== false) { this.internal.services = this.env.asDependent(); await this.internal.services.load(MdnsService); } if (this.state.ble === undefined) { this.state.ble = (await this.agent.load(NetworkServer)).state.ble; } if (this.state.ble !== false) { try { this.env.get(ScannerSet).add(this.env.get(Ble).scanner); } catch (error) { logger.error("Disabling BLE due to initialization error:", error); this.state.ble = false; } } // Ensure the fabric authority is fully initialized const authority = await this.env.load(FabricAuthority); // "Automatic" controller mode - disable commissioning if node is not otherwise configured as a commissionable // device const commissioning = this.agent.get(CommissioningServer); if (commissioning.state.enabled === undefined) { const totalFabrics = this.env.get(FabricManager).length; const controlledFabrics = authority.fabrics.length; if (controlledFabrics === totalFabrics) { commissioning.state.enabled = false; } } this.reactTo(node.lifecycle.online, this.#nodeOnline); if (node.lifecycle.isOnline) { await this.#nodeOnline(); } this.reactTo(node.lifecycle.goingOffline, this.#nodeGoingOffline); // A peer tracked by PeerSet is authoritative for its address, so drop the transient reservation the // allocator made for it during commissioning. const identity = this.env.get(IdentityService); const peers = this.env.get(PeerSet); this.reactTo(peers.added, peer => identity.releasePeerAddress(peer.address)); // Reserve the controller's own node ID in each fabric to prevent assigning it to a peer for (const fabric of authority.fabrics) { identity.reservePeerAddress(PeerAddress({ fabricIndex: fabric.fabricIndex, nodeId: fabric.nodeId })); } this.reactTo(authority.fabricAdded, fabric => identity.reservePeerAddress(PeerAddress({ fabricIndex: fabric.fabricIndex, nodeId: fabric.nodeId })), ); } /** * Allocate a new node address in the given fabric. * * How long an allocated address goes on naming the same device, which anything persisting one has to know: * * - While the peer is commissioned, the address is its own. Every candidate is checked against * {@link IdentityService.peerAddressInUse}, which covers commissioned peers, the controller's own node * IDs, and addresses reserved for a commissioning under way. * - Once the peer is removed, that check no longer holds the address, so a random draw or a node ID the * caller supplies may be the one a removed peer had. * - The `"sequential"` counter only counts up and is stored, so it never hands out a value it has already * handed out. It skips a value that is in use when it arrives at it, and moves past it for good. A * caller-supplied ID does not advance the counter, so the counter reaches that value later: it skips it * while that peer is commissioned, and issues it to another device if the peer is gone by then. */ async allocatePeerAddress(fabricIndex: FabricIndex, nodeId?: NodeId) { const identity = this.env.get(IdentityService); let address: PeerAddress | undefined; try { // Allocate address in a separate transaction we can commit as soon as it's reserved return await this.endpoint.act(async function (agent) { const controller = agent.get(ControllerBehavior); // Lock early to act as semaphor for address assignment await agent.context.transaction.addResources(controller); await agent.context.transaction.begin(); // A caller-supplied NodeId bypasses the allocation loop below, so check it for collisions here. if (nodeId !== undefined && identity.peerAddressInUse({ fabricIndex, nodeId })) { throw new IdentityConflictError( `Cannot assign NodeId ${nodeId} on fabric ${fabricIndex}: the peer address is already in use`, ); } const useSequentialIds = controller.state.nodeIdAssignment !== "random"; let nextNodeId: NodeId = controller.state.nextNodeId ?? NodeId(1); while (nodeId === undefined) { if (useSequentialIds) { nodeId = nextNodeId; nextNodeId++; } else { nodeId = NodeId.randomOperationalNodeId(controller.env.get(Crypto)); } if (identity.peerAddressInUse({ fabricIndex, nodeId })) { nodeId = undefined; } } address = PeerAddress({ fabricIndex, nodeId }); // Reserve prior to commit. We then release below if commit fails identity.reservePeerAddress(address); if (useSequentialIds) { controller.state.nextNodeId = nextNodeId; } return address; }); } catch (e) { // If there's an error but we have an address, ensure it's not allocated if (address) { identity.releasePeerAddress(address); } throw e; } } override async [Symbol.asyncDispose]() { await this.env.close(ActiveDiscoveries); await this.internal.scanner?.close(); if (this.env.owns(FabricAuthority)) { await this.env.close(FabricAuthority); } else { this.env.delete(FabricAuthority); } this.env.delete(ScannerSet); await this.internal.services?.close(); } get fabricAuthorityConfig(): FabricAuthorityConfiguration { const biState = this.endpoint.stateOf(BasicInformationBehavior); return { adminVendorId: biState.vendorId, ...this.state, }; } async #nodeOnline() { // Create the mDNS scanner now that the node is online and discovery is meaningful if (this.state.ip !== false && this.internal.scanner === undefined) { const mdns = this.env.get(MdnsService); this.internal.scanner = new CommissionableMdnsScanner(mdns.names); this.env.get(ScannerSet).add(this.internal.scanner); } // Configure network connections const netTransports = this.env.get(TransportSet); if (this.state.ble) { // no try-catch needed because we already added the scanner in initialize() netTransports.add(this.env.get(Ble).centralInterface); } // Add each pre-existing fabric to discovery criteria and update fabric label if needed const authority = this.env.get(FabricAuthority); for (const fabric of authority.fabrics) { if (fabric.label !== this.state.adminFabricLabel) { await fabric.setLabel(this.state.adminFabricLabel); } this.#enableScanningForFabric(fabric); } this.reactTo(authority.fabricAdded, this.#enableScanningForFabric); } async #nodeGoingOffline() { const netTransports = this.env.get(TransportSet); if (this.state.ble) { netTransports.delete(this.env.get(Ble).centralInterface); } } #enableScanningForFabric(fabric: Fabric) { // Send a one-time wildcard query via DnssdNames so existing operational nodes on this fabric respond and // populate IpService for known peers if (this.internal.services) { const names = this.env.get(MdnsService).names; names.solicitor.solicit({ name: names.get(getFabricQname(fabric.globalId)), recordTypes: [DnsRecordType.PTR], }); } } } export namespace ControllerBehavior { export class Internal { services?: SharedEnvironmentServices; scanner?: CommissionableMdnsScanner; } export class State { /** * Set to false to disable scanning on BLE. * * By default the controller scans via BLE if BLE is available. */ ble?: boolean = undefined; /** * Set to false to disable scanning on IP networks. * * By default the controller always scans on IP networks. */ ip?: boolean = undefined; /** * Node ID assignment strategy. */ nodeIdAssignment: "sequential" | "random" = "sequential"; /** * Next assigned ID when {@link nodeIdAssignment} is "sequential". * * matter.js increments this value automatically after allocating a new node ID. This means that the * "sequential" strategy does not reuse IDs from decommissioned nodes. * * If there is a conflict with an existing ID, matter.js increments this value until it identifies a free ID. */ nextNodeId?: NodeId; /** * Contains the label of the admin fabric which is set for all commissioned devices */ adminFabricLabel = "matter.js"; /** * Contains the FabricId of the admin fabric when a defined number needs to be used because special Certificates * are used. * If not provided, a random FabricId will be generated. */ adminFabricId?: FabricId = undefined; /** * Contains the NodeId of the admin node when a defined number needs to be used because special Certificates * are used. * If not provided, a random NodeId will be generated. */ adminNodeId?: NodeId = undefined; /** * Case Authenticated Tags to be used to commission and connect to devices. */ caseAuthenticatedTags?: CaseAuthenticatedTag[] = undefined; /** * Model of Matter semantics used for peers commissioned or discovered by this controller. * * Supply a model extended with custom or manufacturer-specific clusters (see {@link MatterModel.withClusters}) so * their attributes and commands resolve to real names instead of synthetic `attr$…`/`command$…` identifiers. */ matter?: MatterModel = undefined; } }