/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Behavior } from "#behavior/Behavior.js"; import { ClusterBehavior } from "#behavior/cluster/ClusterBehavior.js"; import type { GlobalAttributeState } from "#behavior/cluster/ClusterState.js"; import { ActionContext } from "#behavior/context/ActionContext.js"; import { NodeActivity } from "#behavior/context/NodeActivity.js"; import { ContextAgents } from "#behavior/context/server/ContextAgents.js"; import { LocalActorContext } from "#behavior/context/server/LocalActorContext.js"; import { IndexBehavior } from "#behavior/system/index/IndexBehavior.js"; import { ProtocolService } from "#node/integration/ProtocolService.js"; import type { Node } from "#node/Node.js"; import { Construction, Diagnostic, Environment, Immutable, ImplementationError, Lifecycle, Lifetime, Logger, MatterAggregateError, MaybePromise, Observable, toHex, UninitializedDependencyError, } from "@matter/general"; import { ClusterModel, DataModelPath } from "@matter/model"; import { InteractionSettings, Read, ReadResult, Val } from "@matter/protocol"; import { AttributeId, ClusterId, EndpointNumber, FabricIndex, Status } from "@matter/types"; import { RootEndpoint } from "../endpoints/root.js"; import { Agent } from "./Agent.js"; import { AttributeNotPresentError, EndpointBehaviorNotClusterError, EndpointBehaviorNotPresentError, EndpointReadFailedError, EndpointReadFailure, InvalidGroupOperationError, } from "./errors.js"; import { Behaviors } from "./properties/Behaviors.js"; import { Commands } from "./properties/Commands.js"; import { EndpointContainer } from "./properties/EndpointContainer.js"; import { EndpointInitializer } from "./properties/EndpointInitializer.js"; import { EndpointLifecycle } from "./properties/EndpointLifecycle.js"; import { Parts } from "./properties/Parts.js"; import { SupportedBehaviors } from "./properties/SupportedBehaviors.js"; import { EndpointType } from "./type/EndpointType.js"; const logger = Logger.get("Endpoint"); /** * Endpoints consist of a hierarchy of parts. This class manages the current state of a single endpoint. * * You can interact with endpoints using an {@link Agent} created with {@link Endpoint.agentFor}. Agents are stateless and * designed for quick instantiation so you can create them as needed then discard. * * Most often direct access to {@link Agent} is transparent as Matter.js acquires an agent as necessary for * {@link Behavior} interactions. */ export class Endpoint { #type: EndpointType; #id?: string; #number?: EndpointNumber; #owner?: Endpoint; #agentType?: Agent.Type; #behaviors: Behaviors; #lifecycle: EndpointLifecycle; #parts?: Parts; #construction: Construction>; #stateView = {} as Immutable>; #events = {} as SupportedBehaviors.EventsOf; #commands?: Commands; #activity?: NodeActivity; #deviceConditions: Set; /** * A string that uniquely identifies an endpoint. * * This ID must be unique amongst all Parts with the same owner. */ get id() { if (this.#id === undefined) { throw new UninitializedDependencyError( this.toString(), "endpoint ID is not yet assigned; set ID or await endpoint.construction to avoid this error", ); } return this.#id; } /** * The endpoint ID or undefined if not yet assigned. */ get maybeId() { return this.#id; } /** * The Matter {@link EndpointNumber} of the endpoint. This uniquely identifies the {@link Endpoint} in the scope of the * Matter node. */ get number(): EndpointNumber { if (this.#number === undefined) { throw new UninitializedDependencyError( this.toString(), "endpoint number is not yet assigned; set number or await endpoint.construction to avoid this error", ); } return this.#number; } /** * The endpoint number or undefined if not yet assigned. */ get maybeNumber() { return this.#number; } /** * The owner of the endpoint. * * Every endpoint but the root endpoint (the "server node") is owned by another endpoint. */ get owner(): Endpoint | undefined { return this.#owner; } /** * Conditions this endpoint asserts, as stated at construction. * * @see {@link Endpoint.EndpointOptions.deviceConditions} */ get deviceConditions(): ReadonlySet { return this.#deviceConditions; } /** * Access an {@link Agent} for this endpoint. * * An {@link Agent} allows you to interact directly with the behaviors supported by the endpoint. Normally you * would use {@link act} to obtain an agent but {@link agentFor} is useful if you need to interact with multiple * endpoints in the same context. */ agentFor(this: Endpoint, context: ActionContext) { return ContextAgents(context).agentFor(this); } get endpointProtocol() { if (this.#number === undefined || !this.env.has(ProtocolService)) { return undefined; } return this.env.get(ProtocolService).protocol[this.#number]; } /** * Search for the owner of a specific type. * * Returns undefined if this owner is not found on the way up to the root endpoint. */ ownerOfType(type: T): Endpoint | undefined { for (let endpoint: Endpoint | undefined = this; endpoint !== undefined; endpoint = endpoint.owner) { if (endpoint.type.deviceType === type.deviceType) { return endpoint as Endpoint; } } } /** * The endpoint's environment. Endpoint implementations use the environment to access platform components such as * storage and network components. */ get env(): Environment { if (this.owner) { return this.owner.env; } return Environment.default; } /** * Access the pool of behaviors supported by this endpoint. */ get behaviors() { return this.#behaviors; } /** * Current state values for all behaviors, keyed by behavior ID. This view is read-only. */ get state() { return this.#stateView; } /** * Current state for a specific behavior ID. * * Be aware that using a string type does not provide type checking and does not enforce the correctness of the used * Behavior type including all enabled features. Because of this the returned state is typed as a plain string * indexed record (Val.Struct). Please ensure to have proper checks in place when using this method with string type. */ stateOf(type: string): Immutable; /** * Current state for a specific behavior. * * This is the recommended way to access state for a specific behavior because it provides proper type checking * and enforces the correctness of the used Behavior type including all enabled features. */ stateOf(type: T): Immutable>; stateOf(type: Behavior.Type | string) { return this.#requirePresent(type, this.maybeStateOf(type as Behavior.Type)); } /** * Version of {@link stateOf} that returns undefined instead of throwing if the requested behavior unsupported. */ maybeStateOf(type: string): Immutable | undefined; /** * Version of {@link stateOf} that returns undefined instead of throwing if the requested behavior unsupported. */ maybeStateOf(type: T): Immutable> | undefined; maybeStateOf(type: Behavior.Type | string) { if (typeof type === "string") { if (!(type in this.#stateView)) { return undefined; } } else { if (!this.behaviors.has(type)) { return undefined; } type = type.id; } return (this.#stateView as Record)[type]; } /** * Update state values. This is a patch operation; it only modifies properties in {@link values}. * * {@link values} is an object with a {@link Behavior.id} as the key and state values as sub-objects. * * Input values must adhere to the {@link Behavior.schema} of the target {@link Behavior}. If not, set will throw * an error. * * This is a transactional operation. An error results in no change. The endpoint will wait for exclusive access * before applying changes. * * @param values the values to change */ async set(values: SupportedBehaviors.StatePatchOf) { await this.act(`set<${this}>`, async agent => { const tx = agent.context.transaction; await tx.begin(); for (const behaviorId in values) { const behavior = agent[behaviorId]; if (!(behavior instanceof Behavior)) { throw new EndpointBehaviorNotPresentError(behaviorId); } const vals = values[behaviorId]; if (vals === undefined) { continue; } await tx.addResources(behavior); const patch = behavior.type.supervisor.patch; if (typeof vals !== "object") { throw new ImplementationError( `State values for ${behaviorId} must be an object, not ${typeof vals}`, ); } if (vals instanceof Date) { throw new ImplementationError( `State values for ${behaviorId} must be an object, not ${vals.constructor.name}`, ); } if (Array.isArray(vals)) { throw new ImplementationError(`State value for ${behaviorId} must be an object, not an array`); } patch(vals, behavior.state, this.path); } }); } /** * Update state values for a single behavior. * * The patch semantics used here are identical to {@link set}. * * This is the recommended way to set state for a single behavior because it provides proper type checking and * enforces the correctness of the used Behavior type including all enabled features. * * @param type the {@link Behavior} to patch * @param values the values to change */ setStateOf(type: T, values: Behavior.PatchStateOf): Promise; /** * Update state values for a single behavior ID. * * The patch semantics used here are identical to {@link set}. * * Be aware that using a string type does not provide type checking and does not enforce the correctness of the used * Behavior type including all enabled features. Expect runtime errors if the provided values are not compatible * with the actual Behavior type. * * @param type the {@link Behavior} to patch * @param values the values to change */ setStateOf(type: string, values: Val.Struct): Promise; async setStateOf(type: Behavior.Type | string, values: Val.Struct) { if (typeof type === "string") { const typeName = type; type = this.behaviors.supported[type]; if (type === undefined) { throw new EndpointBehaviorNotPresentError(typeName); } } await this.act(`setStateOf<${this}>`, async agent => { const behavior = agent.get(type); const tx = agent.context.transaction; await tx.begin(); await tx.addResources(behavior); const patch = behavior.type.supervisor.patch; if (typeof values !== "object") { throw new ImplementationError(`State values for ${type.id} must be an object, not ${typeof values}`); } if (values instanceof Date) { throw new ImplementationError( `State values for ${type.id} must be an object, not ${values.constructor.name}`, ); } if (Array.isArray(values)) { throw new ImplementationError(`State values for ${type.id} must be an object, not an array`); } patch(values, behavior.state, this.path); }); } /** * Typed read of a single behavior's state. * * Same client/server semantics as {@link get}, including the partial-state-on-failure contract. * * When a key-list selector is provided, each returned value may be `undefined` — absent on unsupported * attributes or those excluded by {@link EndpointReadFailedError}. */ getStateOf( type: B, selector?: true, options?: Endpoint.GetOptions, ): Promise>; getStateOf>( type: B, selector: readonly K[], options?: Endpoint.GetOptions, ): Promise<{ readonly [P in K]?: Behavior.StateOf[P] }>; /** * Read of a single behavior's state by string id. * * @throws {@link EndpointBehaviorNotPresentError} if `type` is not present on the endpoint. */ getStateOf(type: string, selector?: readonly string[], options?: Endpoint.GetOptions): Promise; async getStateOf( type: Behavior.Type | string, selector?: BehaviorSelection | readonly string[], options?: Endpoint.GetOptions, ): Promise { const id = typeof type === "string" ? type : type.id; if (!this.behaviors.has(type as Behavior.Type)) { throw new EndpointBehaviorNotPresentError(id); } const slice = (await this.#performRead( { [id]: (selector ?? true) as RawBehaviorSelection } as StateSelector, options, )) as Record; return slice[id]; } /** * Reads the selected behavior state. * * @remarks * **Client endpoint.** Issues a single batched Matter Read for the selected attribute paths * and returns the fresh slice. State is updated as data arrives; reads whose fabric-filter * setting differs from the active subscription are not cached. * * **Partial-state contract on failure.** On any per-path failure status, rejects with * {@link EndpointReadFailedError}. The error carries both the failed paths and the assembled * partial slice for successful paths. * * **Server endpoint.** Returns a snapshot of local immutable state. `fabricFilter` is ignored. */ get(): Promise>>; get( selector: undefined, options?: Endpoint.GetOptions, ): Promise>>; get>(selector: S, options?: Endpoint.GetOptions): Promise>; async get(selector?: StateSelector, options?: Endpoint.GetOptions): Promise { return this.#performRead(selector, options); } /** * Commands for all behaviors keyed by behavior ID. */ get commands() { if (this.#commands === undefined) { this.#commands = Commands(this); } return this.#commands; } /** * Commands for a specific behavior id. Untyped: each command is `Commands.Command`. * * @throws {@link EndpointBehaviorNotPresentError} if `type` is not present on the endpoint. */ commandsOf(type: string): Record; /** Typed variant of {@link commandsOf}; preserves the behavior's command interface. */ commandsOf(type: T): Commands.OfBehavior; commandsOf(type: Behavior.Type | string): unknown { const id = typeof type === "string" ? type : type.id; if (!this.behaviors.has(type as Behavior.Type)) { throw new EndpointBehaviorNotPresentError(id); } return this.commands[id]; } /** * Events for all behaviors keyed by behavior ID. */ get events() { return this.#events; } /** * Events for a specific behavior ID. * * Be aware that using a string type does not provide type checking and does not enforce the correctness of the used * Behavior type including all enabled features. Because of this each event is typed as Observable | undefined. * Please ensure to have proper checks in place when using this method with string type. */ eventsOf(type: string): Immutable>; /** * Events for a specific behavior. * * This is the recommended way to access events for a specific behavior because it provides proper type checking * and enforces the correctness of the used Behavior type including all enabled features. */ eventsOf(type: T): Behavior.EventsOf; eventsOf(type: Behavior.Type | string): unknown { const id = typeof type === "string" ? type : type.id; const present = typeof type === "string" ? id in this.#stateView : this.behaviors.has(type); if (!present) { throw new EndpointBehaviorNotPresentError(id); } return this.#events[id]; } /** * Activated cluster features for a behavior id, keyed by the camelCased feature title - same shape as the * static `features` map on cluster behavior types and as `this.features` inside a server cluster instance. * * @throws {@link EndpointBehaviorNotPresentError} if `type` is not present on the endpoint. * @throws {@link EndpointBehaviorNotClusterError} if `type` is present but is not a cluster behavior. */ featuresOf(type: string): Immutable>; /** Typed variant of {@link featuresOf}; preserves the cluster's feature flag type from {@link ClusterBehavior.Type}. */ featuresOf(type: T): T["features"]; featuresOf(type: ClusterBehavior.Type | string) { return this.#requireClusterBehavior(type).features; } /** Returns undefined if `type` is unknown or not a cluster behavior; otherwise behaves like {@link featuresOf}. */ maybeFeaturesOf(type: string): Immutable> | undefined; maybeFeaturesOf(type: T): T["features"] | undefined; maybeFeaturesOf(type: ClusterBehavior.Type | string) { return this.#installedClusterBehavior(type)?.features; } /** * Global attribute state (clusterRevision, featureMap, attributeList, acceptedCommandList, generatedCommandList) * for a behavior id. These attributes are managed automatically and omitted from the default {@link stateOf} * shape to reduce noise; this accessor exposes them when needed. * * For server clusters values are populated at construction. For client clusters values come from remote * subscription / read responses and may be empty arrays before the first read completes. * * @throws {@link EndpointBehaviorNotPresentError} if `type` is not present on the endpoint. * @throws {@link EndpointBehaviorNotClusterError} if `type` is present but is not a cluster behavior. */ globalsOf(type: string): Immutable; /** Typed variant of {@link globalsOf}; narrows `featureMap` to the cluster's per-feature flag type. */ globalsOf( type: T, ): Immutable & { featureMap: T["features"] }>; globalsOf(type: ClusterBehavior.Type | string) { const cluster = this.#requireClusterBehavior(type); return this.maybeStateOf(cluster.id) as Immutable; } /** Returns undefined if `type` is unknown or not a cluster behavior; otherwise behaves like {@link globalsOf}. */ maybeGlobalsOf(type: string): Immutable | undefined; maybeGlobalsOf( type: T, ): Immutable & { featureMap: T["features"] }> | undefined; maybeGlobalsOf(type: ClusterBehavior.Type | string) { const cluster = this.#installedClusterBehavior(type); if (cluster === undefined) { return undefined; } // Cluster behavior state always carries the global attributes (managed automatically per // {@link GlobalAttributeState}); cast is type-only. return this.maybeStateOf(cluster.id) as Immutable | undefined; } /** Resolve an installed cluster behavior by type or behavior id; returns undefined for unknown / non-cluster. */ #installedClusterBehavior(type: ClusterBehavior.Type | string): ClusterBehavior.Type | undefined { if (typeof type === "string") { const installed = this.behaviors.supported[type]; return installed !== undefined && ClusterBehavior.isType(installed) ? installed : undefined; } return this.behaviors.has(type) ? this.behaviors.typeFor(type)! : undefined; } /** * Resolve an installed cluster behavior, throwing if the behavior is absent or not a cluster behavior. */ #requireClusterBehavior(type: ClusterBehavior.Type | string): ClusterBehavior.Type { const id = typeof type === "string" ? type : type.id; if (typeof type === "string") { const installed = this.behaviors.supported[id]; if (installed === undefined) { throw new EndpointBehaviorNotPresentError(id); } if (!ClusterBehavior.isType(installed)) { throw new EndpointBehaviorNotClusterError(id); } return installed; } if (!this.behaviors.has(type)) { throw new EndpointBehaviorNotPresentError(id); } return this.behaviors.typeFor(type)!; } /** Throw {@link EndpointBehaviorNotPresentError} if `result` is undefined; otherwise return it. */ #requirePresent(type: Behavior.Type | string, result: T | undefined): T { if (result === undefined) { throw new EndpointBehaviorNotPresentError(typeof type === "string" ? type : type.id); } return result; } get construction() { return this.#construction; } /** * Create new endpoint. * * The endpoint will not initialize fully until added to a {@link Node}. You can use {@link Endpoint.add} to * construct and initialize an {@link Endpoint} in one step. */ constructor(config: Endpoint.Configuration | T); /** * Create new endpoint. * * The endpoint will not initialize fully until added to a {@link Node}. You can use {@link Endpoint.add} to * construct and initialize an {@link Endpoint} in one step. */ constructor(type: T, options?: Endpoint.Options); constructor(definition: T | Endpoint.Configuration, options?: Endpoint.Options) { const config = Endpoint.configurationFor(definition, options); this.#type = config.type; // Create construction early so other components can hook events this.#construction = Construction(this); this.#lifecycle = this.createLifecycle(config.isEssential); this.#lifecycle.ready.on(() => this.#logReady()); if (config.id !== undefined) { this.id = config.id; } if (config.number !== undefined) { this.number = config.number; } this.#deviceConditions = new Set(config.deviceConditions); this.#behaviors = new Behaviors(this, config as Record); if (config.owner) { this.owner = config.owner instanceof Agent ? config.owner.endpoint : config.owner; } if (config.parts) { for (const part of config.parts) { this.parts.add(part); } } } set id(id: string) { if (this.#id === id) { return; } if (this.#id !== undefined) { throw new ImplementationError(`${this} ID is already assigned, cannot reassign`); } if (typeof id !== "string") { throw new ImplementationError(`Illegal endpoint ID type "${typeof id}"`); } if (id === "") { throw new ImplementationError("Endpoint ID may not be empty"); } if (id.includes(".")) { throw new ImplementationError('Endpoint ID may not include "."'); } if (this.#owner !== undefined) { this.#container.assertIdAvailable(id, this); } this.#id = id; this.lifecycle.change(EndpointLifecycle.Change.IdAssigned); } protected get container(): undefined | EndpointContainer { return this.owner?.parts; } set number(number: number) { if (this.#number === number) { return; } if (this.#number !== undefined) { throw new ImplementationError( `${this} endpoint number ${this.#number} is already assigned, cannot reassign`, ); } if (typeof number !== "number") { throw new ImplementationError(`Illegal endpoint number type "${typeof number}"`); } if (!Number.isInteger(number)) { throw new ImplementationError(`Endpoint number ${number} is not an integer`); } if (number < 0) { throw new ImplementationError(`Endpoint number ${number} is negative`); } if (number > 0xffff) { throw new ImplementationError(`Endpoint number ${number} is greater than the maximum of 65535`); } if (this.type.deviceClass === RootEndpoint.deviceClass) { if (number !== 0) { throw new ImplementationError("The root endpoint must have ID 0"); } } else { if (number === 0) { throw new ImplementationError("Only root endpoint may have ID 0"); } } this.#number = EndpointNumber(number); this.lifecycle.change(EndpointLifecycle.Change.NumberAssigned); } set owner(owner: Endpoint | undefined) { if (this.#owner === owner) { return; } if (this.#owner) { this.#container.remove(this); } this.#owner = owner; if (owner) { try { this.#container.add(this); } catch (e) { this.#container.remove(this); this.#owner = undefined; throw e; } } } /** * Add a child endpoint. If this endpoint is initialized, awaits child initialization. * * If child initialization fails: * * - If the child is essential (@see {@link EndpointLifecycle#isEssential}), closes the child and rethrows * * - If the child is non-essential then logs the error but leaves the child in place. * * @param endpoint the {@link Endpoint} or {@link Endpoint.Configuration} */ async add(endpoint: Endpoint | Endpoint.Configuration | T): Promise>; /** * Add a child endpoint with separate type and options arguments. * * @param type the {@link EndpointType} of the child endpoint * @param options settings for the new endpoint */ async add(type: T, options?: Endpoint.Options): Promise>; async add( definition: T | Endpoint | Endpoint.Configuration, options?: Endpoint.Options, ) { if (!this.lifecycle.isInstalled) { throw new ImplementationError(`You may not use add() here because ${this} is not installed in a Node`); } await this.construction; let endpoint; if (definition instanceof Endpoint) { endpoint = definition; } else { endpoint = new Endpoint(definition as any, options); } if (this.#lifecycle.isPartsReady) { // Disable the default logging of construction errors because we throw the cause below endpoint.construction.onError(() => {}); } this.parts.add(endpoint); try { await endpoint.construction.ready; } catch (e) { // If endpoint is essential do not allow it to be added if (endpoint.lifecycle.isEssential) { await endpoint.close(); } // For non-essential endpoints just log the error if (!endpoint.lifecycle.isEssential) { logger.error(`Initialization error in non-essential endpoint ${endpoint}:`, e); } throw e; } return endpoint; } /** * The type of endpoint this endpoint implements. */ get type() { return this.#type; } /** * Access child parts. */ get parts() { if (!this.#parts) { this.#parts = new Parts(this); } return this.#parts; } /** * Is this a parent Endpoint? */ get hasParts() { // This doesn't work, apparent TS bug //return !!this.#parts?.size; if (this.#parts?.size) { return true; } return false; } /** * Endpoint information that varies as the endpoint initializes. */ get lifecycle() { return this.#lifecycle; } protected createLifecycle(isEssential?: boolean) { return new EndpointLifecycle(this, isEssential); } /** * Create an {@link Agent.Type} for the endpoint. */ get agentType() { if (!this.#agentType) { this.#agentType = Agent.for(this.type, this.behaviors.supported); } return this.#agentType; } /** * Execute a function against an {@link Agent} for the endpoint. * * Agents provide the highest-leve API for interacting with endpoints. The agent is a composite object with * properties for each supported behavior. * * State changes made by {@link actor} are atomic and made permanent only when the actor exits unless you commit the * transaction manually. * * {@link actor} runs in an "offline" context where ACLs are ignored and all state is read/write. * * The {@link Agent} is destroyed after {@link actor} exits so you should not maintain references to the agent, its * behaviors or associated state. * * {@link actor} may be async. If so, the acting context will remain open until the returned {@link Promise} * resolves. * * @param purpose textual description of operation used for diagnostics * @param actor the function that performs the action * @param options settings of the acting context, see {@link Endpoint.ActOptions} */ act( purpose: string, actor: (agent: Agent.Instance) => MaybePromise, options?: Endpoint.ActOptions, ): MaybePromise; /** * Version of {@link act} without explicit diagnostic purpose. */ act(actor: (agent: Agent.Instance) => MaybePromise): MaybePromise; act( actorOrPurpose: string | ((agent: Agent.Instance) => MaybePromise), actor?: (agent: Agent.Instance) => MaybePromise, options?: Endpoint.ActOptions, ): MaybePromise { let purpose; if (typeof actorOrPurpose === "string") { purpose = actorOrPurpose; } else { actor = actorOrPurpose; purpose = "offline"; } if (typeof actor !== "function") { throw new ImplementationError("Actor must be a function"); } this.construction.assert(this.toString()); if (!this.#activity) { this.#activity = this.env.get(NodeActivity); } const { clientPeerContext } = this; return LocalActorContext.act( purpose, context => { return actor(this.agentFor(context)); }, { activity: this.#activity, lifetime: this.construction, clientPeerContext, abort: options?.abort, }, ); } /** * The peer context for actions on this endpoint if it belongs to a client node, otherwise undefined. * * @internal */ get clientPeerContext(): { fabricIndexOnPeer?: FabricIndex } | undefined { const root = this.ownerOfType(RootEndpoint); if (root === undefined || (root as unknown as Node).nodeType !== "client") { return undefined; } // String-keyed lookup avoids importing CommissioningClient/OperationalCredentialsClient: that would // form a cycle (Endpoint → CommissioningClient → ClientNode → Node → Endpoint). const commissioning = root.behaviors.maybeStateOf("commissioning") as | { fabricIndexOnPeer?: FabricIndex } | undefined; if (commissioning?.fabricIndexOnPeer !== undefined) { return { fabricIndexOnPeer: commissioning.fabricIndexOnPeer }; } const opcreds = root.behaviors.maybeStateOf("operationalCredentials") as | { currentFabricIndex?: FabricIndex } | undefined; const fallback = opcreds?.currentFabricIndex; if (fallback !== undefined && fallback !== FabricIndex.NO_FABRIC) { return { fabricIndexOnPeer: fallback }; } return {}; } /** * Perform "soft" reset of the endpoint, reverting all in-memory structures to uninitialized. */ async reset() { using _resetting = this.construction.join("resetting"); try { // Revert lifecycle to uninitialized this.lifecycle.resetting(); // Reset child parts { using _parts = _resetting.join("parts"); await this.parts.reset(); } // Reset behaviors { using _behaviors = _resetting.join("behaviors"); await this.behaviors.close(); } // Notify { using _lifecycle = _resetting.join("lifecycle"); await this.lifecycle.reset.emit(); } // Set construction to inactive so we can restart this.construction.setStatus(Lifecycle.Status.Inactive); } catch (e) { logger.error(`Unhandled error during reset of ${this}`, e); } } /** * Perform "hard" reset of the endpoint, reverting all in-memory and persistent state to uninitialized. */ async erase() { await this.reset(); await this.env.get(EndpointInitializer).eraseDescendant(this); } /** * Erase all persisted data and destroy the endpoint. */ async delete() { this.construction.assert(this.toString()); this.lifecycle.change(EndpointLifecycle.Change.Destroying); await this.erase(); await this.close(); } /** * Apply a depth-first visitor function to myself and all descendents. */ visit>(visitor: (endpoint: Endpoint) => T): T { const promise = visitor(this); const childIterator = this.parts[Symbol.iterator](); const visitChildren = (): MaybePromise => { for (let next = childIterator.next(); !next.done; next = childIterator.next()) { const promise = next.value.visit(visitor); if (MaybePromise.is(promise)) { return promise.then(visitChildren); } } }; if (MaybePromise.is(promise)) { return promise.then(visitChildren) as T; } return visitChildren() as T; } /** * Destroy the endpoint and its parts. * * Every teardown step runs even when an earlier one fails. A failed deactivation rejects with a * {@link MatterAggregateError}; failures while destroying the endpoint, its parts and its behaviors are logged. */ async close() { this.lifecycle.change(EndpointLifecycle.Change.Destroying); await MatterAggregateError.settleSeries( [ () => this.env.maybeGet(EndpointInitializer)?.deactivateDescendant(this), () => this.#construction.close(), ], `Error closing endpoint ${this}`, ); } async [Construction.destruct]() { await MatterAggregateError.settleSeries( [ () => this.#parts?.close(), () => this.#behaviors?.close(), () => { for (const id in this.#events) { this.#events[id][Symbol.dispose](); } }, () => { this.lifecycle.change(EndpointLifecycle.Change.Destroyed); this.#owner = undefined; }, ], `Error destroying endpoint ${this}`, ); } async [Symbol.asyncDispose]() { await this.close(); } toString() { return this.path.toString(); } /** * Path identifying the endpoint in the Matter data model. */ get path(): DataModelPath { if (this.#owner) { return this.#owner.path.at(this.identity, this.#type.name); } return new DataModelPath(this.identity, this.type?.name); } /** * Diagnostic identity. * * This is an unqualified path segment. */ get identity() { if (this.lifecycle?.hasId) { return this.id; } if (this.lifecycle?.hasNumber) { return this.number; } return "?"; } /** * Asynchronous initialization. * * Derivatives may override to perform async construction prior to full initialization. */ protected initialize() { const initializer = this.env.get(EndpointInitializer); // Configure the endpoint for the appropriate node type initializer.initializeDescendant(this); // Initialize behaviors. Success brings endpoint to "ready" state let promise = this.behaviors.initialize(); // Initialize parts. Success brings endpoint to "tree ready" state if (promise) { promise = promise.then(this.parts.initialize.bind(this.parts)); } else { promise = this.parts.initialize(); } return MaybePromise.then(promise, () => initializer.partsInitialized(this)); } /** * Ensure requirements for construction are met. */ protected assertConstructable() { if (this.#owner === undefined) { throw new ImplementationError(`Endpoint construction initiated without owner`); } if (!this.#owner.#lifecycle.isInstalled) { throw new ImplementationError(`Endpoint construction initiated with uninstalled owner`); } } /** * Complete initialization. Invoked via {@link Construction#start} by the owner. */ [Construction.construct]() { // Sanity checks this.assertConstructable(); // Only a node root holds number 0; Parts refuses it for any part if (this.#number !== undefined && this.#number !== 0) { IndexBehavior.assertNumberAvailable(this, this.#number); } // A node root (number 0) reserves in initialize(), once its own node environment provides the initializer if (this.#number !== 0) { this.env.get(EndpointInitializer).reserveDescendant(this); } // We now consider the endpoint "installed" this.lifecycle.change(EndpointLifecycle.Change.Installed); // Initialize return this.initialize(); } #resolveSelection(selector: StateSelector | undefined): Map { const map = new Map(); if (selector === undefined) { for (const type of this.behaviors) { map.set(type.id, true); } return map; } for (const id of Object.keys(selector)) { const type = this.behaviors.supported[id]; if (type === undefined) { throw new EndpointBehaviorNotPresentError(id); } const raw = (selector as Record)[id]; if (raw === undefined) continue; map.set(id, raw); } return map; } async #performRead( selector: StateSelector | undefined, options: Endpoint.GetOptions | undefined, ): Promise { const selection = this.#resolveSelection(selector); const attributePaths = new Read.AttributePaths(); const clusterLookup = new Map< ClusterId, { behaviorId: string; attrs: Map; attrNameToId: Map } >(); for (const [id, raw] of selection) { const type = this.behaviors.supported[id]!; const schema = type.schema; const endpointId = this.number; if (!(schema instanceof ClusterModel)) { continue; } const clusterId = schema.id as ClusterId | undefined; if (clusterId === undefined) { continue; } const attrs = new Map(); const attrNameToId = new Map(); for (const attr of schema.attributes) { if (attr.id !== undefined && attr.propertyName !== undefined) { const attrId = attr.id as AttributeId; attrs.set(attrId, attr.propertyName); attrNameToId.set(attr.propertyName, attrId); } } clusterLookup.set(clusterId, { behaviorId: id, attrs, attrNameToId }); if (raw === true) { attributePaths.add({ endpointId, clusterId }); continue; } const elements = this.behaviors.elementsOf(type); const allowedNames = elements.attributes; for (const name of raw) { if (!allowedNames.has(name)) { throw new AttributeNotPresentError(id, name); } const attrId = attrNameToId.get(name); if (attrId === undefined) { throw new AttributeNotPresentError(id, name); } attributePaths.add({ endpointId, clusterId, attributeId: attrId }); } } if (attributePaths.paths.length === 0) { return this.#assembleSlice(selection); } const nodeEndpoint = this.ownerOfType(RootEndpoint); if (nodeEndpoint === undefined) { throw new ImplementationError(`${this} is not installed in a node`); } const node = nodeEndpoint as unknown as Node; const failed = new Array(); if (node.nodeType === "group") { throw new InvalidGroupOperationError("Groups do not support reading attributes"); } if (node.nodeType === "client") { const fabricFilter = options?.fabricFilter ?? true; const request = { ...Read({ attributes: [...attributePaths.paths], fabricFilter }), includeKnownVersions: options?.includeKnownVersions, abort: options?.abort, }; const readValues = new Map>(); for await (const chunk of node.interaction.read(request, undefined)) { for await (const report of chunk) { if (report.kind === "attr-value") { const info = clusterLookup.get(report.path.clusterId); if (info !== undefined) { const propName = info.attrs.get(report.path.attributeId); if (propName !== undefined) { let values = readValues.get(info.behaviorId); if (values === undefined) { values = new Map(); readValues.set(info.behaviorId, values); } values.set(propName, report.value); } } } else if (report.kind === "attr-status" && report.status !== Status.Success) { failed.push(readFailureOf(report)); } } } // State view as base; received values overwrite (handles empty response from version-filter match) const stateSlice = this.#assembleSlice(selection) as Record | undefined>; const partial: Record = {}; for (const [id] of selection) { const stateValues = stateSlice[id] ?? {}; const freshValues = readValues.get(id); partial[id] = freshValues?.size ? { ...stateValues, ...Object.fromEntries(freshValues) } : stateValues; } if (failed.length > 0) { this.#excludeFailedPaths(partial, failed, clusterLookup); throw new EndpointReadFailedError({ failed, partial }); } return partial; } const collectFailures = async (stream: ReturnType) => { for await (const chunk of stream) { for await (const report of chunk) { if (report.kind === "attr-status" && report.status !== Status.Success) { failed.push(readFailureOf(report)); } } } }; const request = Read({ attributes: [...attributePaths.paths], fabricFilter: false }); await LocalActorContext.act("endpoint-get", context => collectFailures(node.interaction.read(request, context)), ); const partial = this.#assembleSlice(selection) as Record; if (failed.length > 0) { this.#excludeFailedPaths(partial, failed, clusterLookup); throw new EndpointReadFailedError({ failed, partial }); } return partial; } #excludeFailedPaths( partial: Record, failed: ReadonlyArray, clusterLookup: Map }>, ): void { for (const { path } of failed) { const info = clusterLookup.get(path.clusterId); if (info === undefined) continue; if (path.attributeId === undefined) { delete partial[info.behaviorId]; continue; } const propName = info.attrs.get(path.attributeId); if (propName === undefined) continue; const behaviorState = partial[info.behaviorId]; if (behaviorState === null || typeof behaviorState !== "object") continue; const copy = { ...(behaviorState as Record) }; delete copy[propName]; partial[info.behaviorId] = copy; } } #assembleSlice(selection: Map): unknown { const view = this.#stateView as Record | undefined>; const out: Record = {}; for (const [id, raw] of selection) { const behaviorState = view[id]; if (raw === true) { out[id] = behaviorState ?? {}; continue; } const filtered: Record = {}; for (const name of raw) { filtered[name] = behaviorState?.[name]; } out[id] = filtered; } return out; } #logReady() { logger.info(Diagnostic.strong(this.toString()), "ready", this.diagnosticDict); } /** * Hierarchical diagnostics of endpoint and children. */ get [Diagnostic.value](): unknown { const diagnostics: unknown[] = [ Diagnostic.strong(this.#id === undefined ? "(no id)" : this.id), Diagnostic.dict({ ...this.#diagnosticProps, class: this.constructor.name, }), ]; if (this.#construction.status === Lifecycle.Status.Active) { diagnostics.push(Diagnostic.list([...this.behaviors.detailedDiagnostic, ...this.parts])); } else { diagnostics.push(Diagnostic.weak(`(${this.#construction.status})`)); } return diagnostics; } get [Lifetime.owner](): Lifetime.Owner | undefined { return this.#owner?.construction; } /** * Diagnostic information regarding endpoint state. */ get diagnosticDict() { return Diagnostic.dict({ ...this.#diagnosticProps, behaviors: this.behaviors, }); } get #diagnosticProps() { const type = this.type; return { "endpoint#": typeof this.#number === "number" ? this.number : "(unassigned)", type: `${type.name} (${ type.deviceType === EndpointType.UNKNOWN_DEVICE_TYPE ? "unknown" : `0x${toHex(type.deviceType)}${type.deviceRevision === EndpointType.UNKNOWN_DEVICE_REVISION ? "" : `, rev ${type.deviceRevision}`}` })`, }; } get #container() { const container = this.container; if (container === undefined) { throw new ImplementationError(`No container for endpoint ${this}, which has an owner`); } return container; } } export namespace Endpoint { /** * Settings of the context {@link Endpoint.act} opens. `abort` ends the peer interactions of the commands the actor * invokes on a {@link ClientNode}, also while they wait for the peer. */ export type ActOptions = Pick; export type BehaviorOptions< T extends EndpointType = EndpointType.Empty, O extends EndpointOptions = EndpointOptions, > = { -readonly [K in keyof T["behaviors"] as K extends keyof O ? never : K]?: Behavior.Options; }; export interface EndpointOptions { /** * The owner of the endpoint. * * If provided, takes ownership of the endpoint at construction. */ owner?: Endpoint | Agent; /** * The endpoint's string identifier. Must be unique within the parent. * * If you omit the identifier the node assigns a generated one for you. */ id?: string; /** * The endpoint number. Must be unique within the node. * * If you omit the endpoint number the node assigns a sequential one for you. */ number?: number; /** * Child endpoints. * * This is the inverse of setting {@link owner} above. The endpoint instantiates and takes ownership of child * endpoints at construction time. */ parts?: Iterable; /** * Designates whether an endpoint is essential. * * Endpoints are essential by default but you may disable by setting this to false. */ isEssential?: boolean; /** * Conditions of the product that this endpoint asserts, such as `PhysicalInputs` or `Cooler`. * * A device type's requirements may depend on a condition. matter.js derives the conditions that follow from * the endpoint tree and the node's configuration; state here only those that describe the product. A stated * condition holds in addition to the derived ones, and stating one never makes a condition false. An unknown * name is reported as a violation wherever the endpoint is judged, which in mode `off` of * `endpoint.validation` is only on request. * * @see {@link MatterSpecification.v161.Device} § 1.1.3 */ deviceConditions?: string[]; } export type Options< T extends EndpointType = EndpointType.Empty, O extends EndpointOptions = EndpointOptions, > = BehaviorOptions & O; export type Configuration< T extends EndpointType = EndpointType.Empty, O extends EndpointOptions = EndpointOptions, > = Options & { type: T }; /** * Definition of an endpoint. May be an {@link EndpointType}, {@link Configuration}, or a {@link Endpoint} * instance. */ export type Definition = T | Configuration | Endpoint; /** * Options for {@link Endpoint.get} and {@link Endpoint.getStateOf}. * * A read on a {@link ClientNode} does not run in the context of a surrounding {@link Endpoint.act}, so the abort of * that context does not reach it; pass `abort` to end the read, also while it waits for a sleeping ICD peer. */ export interface GetOptions extends Pick { /** * When `false`, the underlying Matter Read is not fabric-filtered. Defaults to `true`. */ fabricFilter?: boolean; /** * When `true`, skips automatic data-version filter injection so the server returns all attribute values * regardless of whether they have changed since the last read or subscription. Defaults to `false`. */ includeKnownVersions?: boolean; } /** * Obtain a configuration from constructor parameters. */ export function configurationFor( definition: T | Endpoint.Configuration, options?: Endpoint.Options, ) { if ((definition as EndpointType).deviceType) { return { ...options, type: definition as T, } as Configuration; } return definition as Configuration; } /** * Obtain an endpoint for the given {@link Definition}. */ export function partFor(definition: Definition): Endpoint { if (definition instanceof Endpoint) { return definition; } return new Endpoint(definition); } } export type BehaviorOf = T["behaviors"][keyof T["behaviors"]]; export type BehaviorAt = T["behaviors"][K]; export type BehaviorSelection = true | readonly (keyof Behavior.StateOf)[]; export type RawBehaviorSelection = true | readonly string[]; export type StateSelector = { [K in keyof T["behaviors"]]?: string extends K ? RawBehaviorSelection : T["behaviors"][K] extends Behavior.Type ? BehaviorSelection : RawBehaviorSelection; }; export type StateSliceOf = S extends undefined ? Immutable> : { [K in keyof S & keyof T["behaviors"]]: S[K] extends true ? Immutable>> : S[K] extends readonly (infer A)[] ? Immutable< Partial< Pick>, Extract>>> > > : never; }; function readFailureOf({ path, status, clusterStatus, received }: ReadResult.AttributeStatus): EndpointReadFailure { return { path, status, clusterStatus, received }; }