/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { ActionContext } from "#behavior/context/ActionContext.js"; import { CommissioningClient } from "#behavior/system/commissioning/CommissioningClient.js"; import { ClientNetworkRuntime } from "#behavior/system/network/ClientNetworkRuntime.js"; import { NetworkClient } from "#behavior/system/network/NetworkClient.js"; import { NetworkRuntime } from "#behavior/system/network/NetworkRuntime.js"; import { Agent } from "#endpoint/Agent.js"; import type { Endpoint } from "#endpoint/Endpoint.js"; import { ClientNodeEndpoints } from "#endpoint/properties/ClientNodeEndpoints.js"; import { EndpointInitializer } from "#endpoint/properties/EndpointInitializer.js"; import { EndpointType } from "#endpoint/type/EndpointType.js"; import { MutableEndpoint } from "#endpoint/type/MutableEndpoint.js"; import { ClientCacheBuffer } from "#storage/client/ClientCacheBuffer.js"; import { ClientNodeStore } from "#storage/client/ClientNodeStore.js"; import { RemoteWriter } from "#storage/client/RemoteWriter.js"; import { ServerNodeStore } from "#storage/server/ServerNodeStore.js"; import { DependencyLifecycleError, Diagnostic, Duration, Identity, ImplementationError, InternalError, Lifecycle, Logger, MatterAggregateError, MaybePromise, } from "@matter/general"; import { Matter, MatterModel } from "@matter/model"; import { Interactable, OccurrenceManager, PeerAddress, PeerSet } from "@matter/protocol"; import { ClientEndpointInitializer } from "./client/ClientEndpointInitializer.js"; import { ClientNodeInteraction } from "./client/ClientNodeInteraction.js"; import { ClientStructure } from "./client/ClientStructure.js"; import { ClientNodeLifecycle } from "./ClientNodeLifecycle.js"; import { Node } from "./Node.js"; import type { ServerNode } from "./ServerNode.js"; const logger = Logger.get("ClientNode"); /** * A remote Matter {@link Node}. * * Client nodes may be peers (commissioned into a shared fabric) or commissionable, in which they are not usable until * you invoke {@link commissioned}. * * A node's `id` names it locally and may be reissued after the node is removed. Persist a {@link PeerAddress} when you * need to refer to the same logical fabric/node identity across local restarts. That address names one device for as * long as the device stays commissioned. Once the node is removed the address may be handed to another * device — `ControllerBehavior.allocatePeerAddress` documents when — so a reference kept for longer must cope * both with finding nothing and with finding someone else. */ export class ClientNode extends Node { #matter?: MatterModel; #interaction?: ClientNodeInteraction; #blockInteractions = false; #cachedPeerAddress?: PeerAddress; constructor(options: ClientNode.Options) { const opts = { ...options, number: 0, // Create an unfrozen type so we can set the revision when we see the descriptor type: MutableEndpoint(ClientNode.RootEndpoint), }; super(opts); // Block the OccurrenceManager from the parent environment so we don't attempt to record events from peers. // Deleting blocks it; closing would close the parent's manager this.env.delete(OccurrenceManager); this.env.set(Node, this); this.env.set(ClientNode, this); this.#matter = options.matter; } override readonly nodeType: "client" | "group" = "client"; /** * Model of Matter semantics understood by this node. * * Matter elements missing from this model will not support all functionality. */ override get matter(): MatterModel { return this.#matter ?? this.env.maybeGet(MatterModel) ?? Matter; } override get lifecycle(): ClientNodeLifecycle { return super.lifecycle as ClientNodeLifecycle; } override get endpoints(): ClientNodeEndpoints { return new ClientNodeEndpoints(this); } protected get store() { return this.env.get(ServerNodeStore).clientStores.storeForNode(this); } // This needs to be sync to ensure a sync initialization override initialize() { const store = this.store; this.env.set(ClientNodeStore, store); const initializer = new ClientEndpointInitializer(this); this.env.set(EndpointInitializer, initializer); this.env.set(ClientStructure, initializer.structure); store.write = RemoteWriter(this, initializer.structure); ClientCacheBuffer.configure(this, store); initializer.structure.loadCache(store.endpointStores); const promise = super.initialize(); if (store.isPreexisting && promise !== undefined) { // We initialize ClientNodes on-demand but want them fully initialized for immediate use. This means // initialization must be synchronous. Enforce this here to ensure we don't accidentally break this // contract. A behavior that fails to load also yields a promise, so adopt its rejection rather than // discarding it; otherwise the real cause escapes as an unhandled rejection behind this diagnostic return promise.then(() => { throw new InternalError("Unsupported async initialization detected when loading known peer"); }); } return promise; } override get owner(): ServerNode { const owner = super.owner; if (owner === undefined) { throw new InternalError("Client node is missing owner"); } return super.owner as ServerNode; } override set owner(owner: ServerNode) { super.owner = owner; } /** * {@link owner} throws {@link InternalError} once the node is destroyed; a gone node rejects with its lifecycle * error instead. */ get #peers() { this.lifecycle.assertNotGone(); return this.owner.peers; } /** * Add this node to a fabric. * * Rejects as `Peers.runCommissioning()` describes if the node is gone or a commission or decommission of it is * already in progress. */ async commission(options: CommissioningClient.CommissioningOptions) { await this.#peers.runCommissioning(this, () => this.act("commission", agent => agent.commissioning.commission(options)), ); } /** * Remove this node from the fabric (if commissioned) and locally. * This method tries to communicate with the device to decommission it properly and will fail if the device is * unreachable. If the device does not confirm the removal, the node is kept and stays usable. Rejects as * `Peers.runDecommissioning()` describes if the node is gone or a commission or decommission of it is already in * progress. * If you cannot reach the device, use {@link delete} instead. */ async decommission() { await this.#peers.runDecommissioning(this, async () => { if (this.lifecycle.isCommissioned) { this.statusUpdate("decommissioning"); await this.act("decommission", agent => agent.commissioning.decommission()); } await this.delete(); }); } /** * Open a Basic Commissioning Window on this peer (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) { await this.act(agent => agent.commissioning.openBasicCommissioningWindow(commissioningTimeout)); } /** * Open an Enhanced Commissioning Window on this peer using a freshly generated random passcode. * * @returns the pairing codes and the values they encode. */ async openEnhancedCommissioningWindow(commissioningTimeout?: Duration) { return await this.act(agent => agent.commissioning.openEnhancedCommissioningWindow(commissioningTimeout)); } /** * Force-remove the node without first decommissioning. * * If the node is still available, you should use {@link decommission} to remove it properly from the fabric and only use * this method as fallback. You should inform the user that manual factory-reset may be necessary. */ override async delete() { const address = this.peerAddress; try { await super.delete(); } finally { // Ensure there is no remaining @matter/protocol Peer installed. This may occur if deleted while still // commissioned, and must hold even when teardown fails or the peer outlives its fabric if (address) { await this.env.maybeGet(PeerSet)?.get(address)?.delete(); } } } override async erase() { await this.lifecycle.mutex.produce(this.eraseWithMutex.bind(this)); } /** * Disable the node. * * This shuts down any active connections and prevents future connections until re-enabled. */ async disable() { if (this.state.network.isDisabled) { return; } // A disabled node should be offline; setting this before teardown also lets the connection-state engine settle // straight to Disconnected instead of flashing Reconnecting as the subscription drops. this.lifecycle.targetState = "offline"; await this.lifecycle.mutex.produce(async () => { // Drop the subscription before flipping isDisabled so the connection-state engine cannot momentarily read // Connected while disabling (subscriptionActive is checked ahead of isDisabled). await this.cancelWithMutex(); await this.setStateOf(NetworkClient, { isDisabled: true }); }); } /** * Enable the node. * * If the node is disabled but reachable, this brings it online. */ async enable() { if (!this.state.network.isDisabled) { return; } await this.setStateOf(NetworkClient, { isDisabled: false }); await this.start(); } protected async eraseWithMutex() { await this.cancelWithMutex(); await super.resetWithMutex(); this.#cachedPeerAddress = undefined; await this.env.get(EndpointInitializer).eraseDescendant(this); } protected override async resetWithMutex() { this.#cachedPeerAddress = undefined; await super.resetWithMutex(); } protected override createLifecycle(): ClientNodeLifecycle { return new ClientNodeLifecycle(this); } protected createRuntime(): NetworkRuntime { return new ClientNetworkRuntime(this); } async prepareRuntimeShutdown() {} protected override async cancelWithMutex() { // TODO Revisit this blocking mechanism because we might need it more general? // Maybe let it be created but have a check in ClientNodeInteraction which decided if allowed or not? this.#blockInteractions = true; try { const interaction = this.#interaction; this.#interaction = undefined; await MatterAggregateError.settleSeries( [() => interaction?.close(), () => super.cancelWithMutex()], `Error disconnecting ${this}`, ); } finally { this.#blockInteractions = false; } } // A peer belongs to its host's Peers, which closes it within the host's teardown protected override get isRuntimeWorker() { return false; } protected override get container() { return this.owner?.peers; } override act( purpose: string, actor: (agent: Agent.Instance) => MaybePromise, options?: Endpoint.ActOptions, ): MaybePromise; override act(actor: (agent: Agent.Instance) => MaybePromise): MaybePromise; override act( actorOrPurpose: string | ((agent: Agent.Instance) => MaybePromise), actor?: (agent: Agent.Instance) => MaybePromise, options?: Endpoint.ActOptions, ): MaybePromise { if (this.construction.status === Lifecycle.Status.Inactive) { // Between the reset and the close of a deletion; restarting would revive a node that is going away this.lifecycle.assertNotGone(); this.construction.start(); } if (this.construction.status === Lifecycle.Status.Initializing) { return this.construction.then(() => (super.act as any)(actorOrPurpose, actor, options)); } return (super.act as any)(actorOrPurpose, actor, options); } get interaction(): Interactable { if (this.#interaction === undefined) { if (this.#blockInteractions) { throw new ImplementationError("Cannot access interaction of a shutting-down node"); } this.#interaction = new ClientNodeInteraction(this); } return this.#interaction; } get peerAddress(): PeerAddress | undefined { // Whenever the commissioning backing is loaded its state is authoritative - mirror it (set or cleared) // so the cache cannot survive a decommission as a stale value const state = this.behaviors.maybeStateOf("commissioning"); if (state !== undefined) { const live = PeerAddress(state.peerAddress as PeerAddress | undefined); this.#cachedPeerAddress = live; return live; } if (this.#cachedPeerAddress !== undefined) { return this.#cachedPeerAddress; } // Backing not yet loaded; consult storage directly, tolerating teardown try { const stored = PeerAddress(this.store.storeForEndpoint(this).peerAddress as PeerAddress | undefined); if (stored !== undefined) { this.#cachedPeerAddress = stored; return stored; } } catch (error) { DependencyLifecycleError.accept(error); logger.info("Cannot resolve peer address from storage:", error); } return undefined; } override get identity() { const peerAddress = this.peerAddress; // Use the peer address as a log identifier if present if (peerAddress) { return PeerAddress(peerAddress).toString(); } // Fall back to persistence ID return super.identity; } protected override statusUpdate(message: string): void { // Log client node status updates as info rather than notice and change the log facility to make clear it's a // client logger.info(Diagnostic.strong(this.toString()), message); } } export namespace ClientNode { export interface Options extends Node.Options { matter?: MatterModel; } export const RootEndpoint = MutableEndpoint({ ...Node.CommonRootEndpoint, deviceRevision: EndpointType.UNKNOWN_DEVICE_REVISION, }).with(CommissioningClient, NetworkClient); export interface RootEndpoint extends Identity {} } Object.freeze(ClientNode.RootEndpoint);