/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { ActionContext } from "#behavior/context/ActionContext.js"; import { NodeActivity } from "#behavior/context/NodeActivity.js"; import { IndexBehavior } from "#behavior/system/index/IndexBehavior.js"; import { NetworkBehavior } from "#behavior/system/network/NetworkBehavior.js"; import { NetworkRuntime } from "#behavior/system/network/NetworkRuntime.js"; import { PartsBehavior } from "#behavior/system/parts/PartsBehavior.js"; import { Endpoint } from "#endpoint/Endpoint.js"; import { Endpoints } from "#endpoint/properties/Endpoints.js"; import { EndpointType } from "#endpoint/type/EndpointType.js"; import { MutableEndpoint } from "#endpoint/type/MutableEndpoint.js"; import { asError, Construction, Diagnostic, DiagnosticPresentation, DiagnosticSource, Environment, Identity, ImplementationError, Logger, MatterAggregateError, } from "@matter/general"; import { Matter, MatterModel } from "@matter/model"; import { Interactable } from "@matter/protocol"; import type { EndpointNumber } from "@matter/types"; import { RootEndpoint } from "../endpoints/root.js"; import { ProtocolService } from "./integration/ProtocolService.js"; import { NodeLifecycle } from "./NodeLifecycle.js"; const logger = Logger.get("Node"); /** * A Matter Node. * * In Matter, a "node" is an individually addressable top-level network resource. */ export abstract class Node extends Endpoint { #environment: Environment; #runtime?: NetworkRuntime; #startInProgress = false; #closeInProgress = false; constructor(config: Node.Configuration) { const parentEnvironment = config.environment ?? config.owner?.env ?? Environment.default; // Ensure runtime is shared across components void parentEnvironment.root.runtime; if (config.id === undefined) { config.id = `node${parentEnvironment.vars.increment("node.nextFallbackId")}`; } super(config); // We create a local environment so nodes can offer node-specific services via the environment this.#environment = new Environment(config.id, parentEnvironment); this.#environment.set(NodeActivity, new NodeActivity()); this.#environment.set(ProtocolService, new ProtocolService(this)); if (this.lifecycle.hasNumber) { if (this.number !== 0) { throw new ImplementationError("The root endpoint ID must be 0"); } } else { this.number = 0; } this.lifecycle.online.on(() => { this.statusUpdate("is online"); }); this.lifecycle.offline.on(() => { this.statusUpdate("is offline"); }); this.lifecycle.goingOffline.on(() => { this.statusUpdate("going offline"); }); } override get env() { return this.#environment; } /** * Model of Matter semantics understood by this node. Defaults to the standard model; client nodes may narrow or * extend it so remote clusters resolve to real element names. */ get matter(): MatterModel { return Matter; } /** * The optimized view that supports local Matter protocol implementation. */ get protocol() { return this.env.get(ProtocolService).protocol; } override get lifecycle(): NodeLifecycle { return super.lifecycle as NodeLifecycle; } /** * Bring the node online. */ async start() { if (this.lifecycle.isOnline || this.#startInProgress) { return; } this.#startInProgress = true; try { this.lifecycle.targetState = "online"; await this.lifecycle.mutex.produce(this.startWithMutex.bind(this)); } catch (error) { this.lifecycle.targetState = "offline"; throw error; } finally { this.#startInProgress = false; } } protected async startWithMutex() { if (this.isRuntimeWorker) { this.env.runtime.add(this); } try { await this.construction.ready; if (this.#runtime) { return; } this.statusUpdate("going online"); this.#runtime = this.createRuntime(); this.#runtime.construction.start(); this.#environment.set(NetworkRuntime, this.#runtime); await this.#runtime.construction.ready; await this.act("network startup", agent => agent.get(NetworkBehavior).startup()); } catch (e) { // If a runtime instance got created, tear it down. Detach state synchronously to avoid a deadlock: // runtime.close() awaits activity.inactive, but we may be called from within an activity (e.g. // commissioning) that can't close until we return. Closing in the background breaks the cycle. if (this.#runtime) { const runtime = this.#runtime; this.#environment.delete(NetworkRuntime, runtime); this.#runtime = undefined; this.behaviors.internalsOf(NetworkBehavior).runtime = undefined; // We intentionally do not track this promise because it is likely to hang runtime.close().catch(e => { logger.warn("Error closing network runtime", asError(e)); }); } if (this.isRuntimeWorker) { this.env.runtime.delete(this); } throw e; } } /** * @deprecated use {@link start} */ async bringOnline() { return this.start(); } /** * Run the node in standalone mode. Returns when the node is closed. */ async run() { await this.start(); await this.construction.closed; } /** * Take the node offline but leave state and structure intact. Happens automatically on close. * * Once the node is offline you may use {@link start} to bring the node online again. */ async stop() { this.lifecycle.targetState = "offline"; await this.lifecycle.mutex.produce(this.cancelWithMutex.bind(this)); } /** * @deprecated use {@link stop} */ cancel() { return this.stop(); } /** * All endpoints owned by the node. * * Normally you access endpoints via {@link parts} but you can use this property access endpoints directly by * {@link EndpointNumber}. */ get endpoints(): Endpoints { return new Endpoints(this); } protected async cancelWithMutex() { if (!this.#runtime) { return; } await MatterAggregateError.settleSeries( [ () => this.act(agent => this.lifecycle.goingOffline.emit(agent.context)), async () => { const runtime = this.#runtime; if (runtime === undefined) { return; } this.#runtime = undefined; this.#environment.delete(NetworkRuntime, runtime); await runtime.close(); }, ], `Error taking ${this} offline`, ); } override async close() { if (this.#closeInProgress) { return; } this.#closeInProgress = true; try { this.lifecycle.targetState = "offline"; await this.lifecycle.mutex.close(); await this.closeWithMutex(); } finally { this.#closeInProgress = false; } } protected async closeWithMutex() { // The runtime is not designed to operate with a node that is shutting down so destroy it before performing // actual close await MatterAggregateError.settleSeries( [() => (this.#runtime ? this.cancelWithMutex() : undefined), () => super.close()], `Error closing node ${this}`, ); } override async reset() { await this.lifecycle.mutex.produce(this.resetWithMutex.bind(this)); } protected async resetWithMutex() { return super.reset(); } /** * Create the network runtime. */ protected abstract createRuntime(): NetworkRuntime; /** * An {@link Interactable} that allows for execution of Matter interactions against this node. */ abstract interaction: Interactable; abstract readonly nodeType: "server" | "client" | "group"; protected abstract prepareRuntimeShutdown(): Promise; /** * Whether the node registers with the environment's runtime when it starts, so that shutting down the runtime * closes it. */ protected get isRuntimeWorker() { return true; } /** * Sections this node adds to its diagnostics, each a heading followed by its content. */ protected get diagnosticSections(): unknown[] { return []; } get [DiagnosticPresentation.name]() { return ["Runtime for", Diagnostic.strong(this.toString())]; } get [Diagnostic.value](): unknown { const nodeActivity = this.#environment.get(NodeActivity); using _activity = nodeActivity.begin("diagnostics"); return Diagnostic.node("🧩", this.id, { children: [ Diagnostic.strong("Structure"), Diagnostic.list([super[Diagnostic.value]]), Diagnostic.strong("Activity"), nodeActivity[Diagnostic.value], ...this.diagnosticSections, ], }); } protected override createLifecycle(): NodeLifecycle { return new NodeLifecycle(this); } protected statusUpdate(message: string) { logger.notice(Diagnostic.strong(this.toString()), message); } override async [Construction.destruct]() { await MatterAggregateError.settleSeries( [ () => this.cancelWithMutex(), () => super[Construction.destruct](), () => DiagnosticSource.delete(this), () => this.#environment[Symbol.dispose](), ], `Error destroying node ${this}`, ); } } export namespace Node { export interface NodeOptions extends Endpoint.EndpointOptions { environment?: Environment; } export type Options< T extends Node.CommonRootEndpoint = Node.CommonRootEndpoint, O extends NodeOptions = NodeOptions, > = Endpoint.Options; export type Configuration< T extends Node.CommonRootEndpoint = Node.CommonRootEndpoint, O extends NodeOptions = NodeOptions, > = Endpoint.Configuration; export function nodeConfigFor( defaultType: T, configuration: undefined | T | Configuration, options: Options, ): Node.Configuration { if (configuration === undefined) { return { type: defaultType, ...options, }; } if ((configuration as EndpointType).deviceType !== undefined) { return { type: configuration as T, ...options, }; } return { type: defaultType, ...configuration, } as Node.Configuration; } export function forEndpoint(endpoint: Endpoint): Node { const node = endpoint.ownerOfType(RootEndpoint); if (node === undefined) { throw new ImplementationError(`Cannot complete operation because ${endpoint} is not installed in a node`); } if (!(node instanceof Node)) { throw new ImplementationError(`Root endpoint for ${endpoint} is not a node`); } return node; } /** * Common root endpoint definition for all nodes. */ export const CommonRootEndpoint = MutableEndpoint({ name: RootEndpoint.name, deviceType: RootEndpoint.deviceType, deviceRevision: RootEndpoint.deviceRevision, deviceClass: RootEndpoint.deviceClass, requirements: RootEndpoint.requirements, behaviors: { parts: PartsBehavior, index: IndexBehavior, }, }); export interface CommonRootEndpoint extends Identity {} }