/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import type { Behavior } from "#behavior/Behavior.js"; import { isClientBehavior } from "#behavior/cluster/cluster-behavior-utils.js"; import { ClusterBehavior } from "#behavior/cluster/ClusterBehavior.js"; import { ActionContext } from "#behavior/context/ActionContext.js"; import { LocalActorContext } from "#behavior/context/server/LocalActorContext.js"; import type { BehaviorBacking } from "#behavior/internal/BehaviorBacking.js"; import { Datasource } from "#behavior/state/managed/Datasource.js"; import { RootSupervisor } from "#behavior/supervision/RootSupervisor.js"; import type { Supervision } from "#behavior/supervision/Supervision.js"; import { maybeConfigOfConstructor } from "#behavior/supervision/SupervisionConfig.js"; import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js"; import type { DescriptorBehavior } from "#behaviors/descriptor"; import type { Endpoint } from "#endpoint/Endpoint.js"; import type { Node } from "#node/Node.js"; import { asError, camelize, Diagnostic, ImplementationError, isObject, Logger, MaybePromise, Observable, Transaction, } from "@matter/general"; import { AcceptedCommandList, AttributeList, ClusterModel, CommandModel, DataModelPath, ElementTag, GeneratedCommandList, Matter, } from "@matter/model"; import type { AttributeTypeProtocol, ClusterProtocol, ClusterTypeProtocol, CollectionProtocol, CommandInvokeHandler, CommandTypeProtocol, EndpointProtocol, InteractionSession, NodeProtocol, } from "@matter/protocol"; import { EventTypeProtocol, FabricManager, hasRemoteActor, Mark, OccurrenceManager, Val } from "@matter/protocol"; import { AttributeId, AttributePath, ClusterId, CommandId, CommandPath, DeviceTypeId, EndpointNumber, EventId, EventPath, FabricIndex, StatusResponse, TlvOfModel, TlvVoid, WildcardPathFlags as WildcardPathFlagsType, } from "@matter/types"; const logger = Logger.get("ProtocolService"); /** * Protocol view of a {@link Node} * * This service maintains an optimized {@link NodeProtocol} that maps to the state of a {@link Node}. * * The protocol view only contains endpoints and server clusters with active backings; client clusters are excluded * as they are not part of the node's server-facing data model. {@link Behaviors} conveys backing state via the * public interface. */ export class ProtocolService { readonly #state: NodeState; constructor(node: Node) { this.#state = new NodeState(node); } /** * Invoked by a backing when initialized. */ addCluster(backing: BehaviorBacking) { const { schema } = backing.type; if (schema?.tag !== ElementTag.Cluster || schema.id === undefined) { return; } // Client clusters are not server-facing and must not be exposed to incoming interactions. if (isClientBehavior(backing.type)) { return; } this.#state.stateFor(backing.endpoint).addCluster(backing); } /** * Invoked by a backing when closed. */ deleteCluster(backing: BehaviorBacking) { if (this.#state.hasEndpoint(backing.endpoint)) { this.#state.stateFor(backing.endpoint).deleteCluster(backing); } } /** * Invoked by a backing when there is a state change. * * This optimized path allows us to broadcast state changes without registering observers for every change. */ handleChange(backing: BehaviorBacking, props: string[]) { const clusterId = backing.type.schema.id as ClusterId | undefined; if (clusterId === undefined) { return; } const namesToIds = backing.type.supervisor.propertyNamesAndIds; const attrs = props.map(name => namesToIds.get(name)).filter(id => id !== undefined); this.protocol.attrsChanged.emit(backing.endpoint.number, clusterId, attrs, backing.datasource.version); } /** * The {@link NodeProtocol}. */ get protocol() { return this.#state.protocol; } } const WildcardPathFlags = { skipRootNode: 1 << WildcardPathFlagsType.wildcardSkipRootNode.offset, skipGlobalAttributes: 1 << WildcardPathFlagsType.wildcardSkipGlobalAttributes.offset, skipAttributeList: 1 << WildcardPathFlagsType.wildcardSkipAttributeList.offset, skipCommandLists: 1 << WildcardPathFlagsType.wildcardSkipCommandLists.offset, skipCustomElements: 1 << WildcardPathFlagsType.wildcardSkipCustomElements.offset, skipFixedAttributes: 1 << WildcardPathFlagsType.wildcardSkipFixedAttributes.offset, skipChangesOmittedAttributes: 1 << WildcardPathFlagsType.wildcardSkipChangesOmittedAttributes.offset, skipDiagnosticsClusters: 1 << WildcardPathFlagsType.wildcardSkipDiagnosticsClusters.offset, }; class NodeState { readonly protocol: NodeProtocol; readonly #endpoints = new Set(); readonly #endpointStates = {} as Record; constructor(node: Node) { let fabrics: FabricManager | undefined; let eventHandler: OccurrenceManager | undefined; this.protocol = { matter: Matter, nodeIdFor(index: FabricIndex) { if (!fabrics) { fabrics = node.env.get(FabricManager); } return fabrics.maybeFor(index)?.nodeId; }, get eventHandler() { if (eventHandler === undefined) { eventHandler = node.env.get(OccurrenceManager); } return eventHandler; }, [Symbol.iterator]: this.#endpoints[Symbol.iterator].bind(this.#endpoints), attrsChanged: new Observable< [endpointId: EndpointNumber, clusterId: ClusterId, changes: AttributeId[], version: number] >(), toString() { return `node-proto#${node.id}`; }, inspect() { return this.toString(); }, inspectPath(path: AttributePath | EventPath | CommandPath) { return resolvePathForNode(this, path); }, } satisfies NodeProtocol & { toString(): string; inspect(): string } as NodeProtocol; } stateFor(endpoint: Endpoint) { const { number } = endpoint; let state = this.#endpointStates[number]; if (state !== undefined) { return state; } state = new EndpointState(this, endpoint); this.protocol[number] = state.protocol; this.#endpoints.add(state.protocol); this.#endpointStates[number] = state; return state; } hasEndpoint(endpoint: Endpoint) { return endpoint.number in this.#endpointStates; } deleteEndpoint(endpoint: EndpointProtocol) { delete this.protocol[endpoint.id]; this.#endpoints.delete(endpoint); delete this.#endpointStates[endpoint.id]; } } class EndpointState { readonly protocol: EndpointProtocol; readonly #node: NodeState; readonly #activeClusters = new Set(); readonly #clusters = new Set(); readonly #backings = new Map(); constructor(node: NodeState, endpoint: Endpoint) { this.#node = node; const number = endpoint.number; this.protocol = { id: number, wildcardPathFlags: number === 0 ? WildcardPathFlags.skipRootNode : 0, path: endpoint.path, name: endpoint.type.name, deviceTypes: [], toString() { return `endpoint-proto#${this.id}`; }, inspect() { return this.toString(); }, [Symbol.iterator]: this.#clusters[Symbol.iterator].bind(this.#clusters), } satisfies EndpointProtocol & { toString(): string; inspect(): string } as EndpointProtocol; } addCluster(backing: BehaviorBacking) { const type = clusterTypeProtocolOf(backing); if (!type) { return; } // A second behavior for the same cluster would silently replace the first on the wire while both keep acting const existing = this.#backings.get(type.id); if (existing !== undefined && existing.type.id !== backing.type.id) { throw new ImplementationError( `Endpoint ${backing.endpoint} has two behaviors for cluster ${type.name}: "${existing.type.id}" and "${backing.type.id}"`, ); } this.#backings.set(type.id, backing); const cluster = new ClusterState(type, backing); // When descriptor behavior initializes, sync device types if (backing.type.id === "descriptor") { this.#updateDeviceTypes(backing.endpoint.state.descriptor.deviceTypeList); (backing.events as DescriptorBehavior["events"]).deviceTypeList$Changed.on( this.#updateDeviceTypes.bind(this), ); } this.protocol[cluster.type.id] = cluster; this.#activeClusters.add(cluster.type.id); this.#clusters.add(cluster); // Cluster added, emit all attributes as changed const attrs = [...cluster.type.attributes] .filter(attr => attr.limits.readable && !attr.changesOmitted) .map(attr => attr.id); if (attrs.length) { this.#node.protocol.attrsChanged.emit(this.protocol.id, cluster.type.id, attrs, cluster.version); } } deleteCluster(backing: BehaviorBacking) { const { schema } = backing.type; if (schema === undefined) { return; } const { tag, id } = schema; if (tag !== ElementTag.Cluster || id === undefined) { return; } const protocol = this.protocol[id]; if (protocol) { this.#clusters.delete(protocol); delete this.protocol[id]; } const clusterId = ClusterId(id); this.#activeClusters.delete(clusterId); if (this.#backings.get(clusterId) === backing) { this.#backings.delete(clusterId); } if (!this.#activeClusters.size) { this.#node.deleteEndpoint(this.protocol); } } #updateDeviceTypes(deviceTypeList: Readonly<{ deviceType: DeviceTypeId }[]> | undefined) { if (deviceTypeList === undefined) { logger.warn(`Endpoint ${this.protocol.id} descriptor is missing mandatory DeviceTypeList`); } this.protocol.deviceTypes = deviceTypeList?.map(dt => dt.deviceType) ?? []; } } class ClusterState implements ClusterProtocol { readonly type: ClusterTypeProtocol; readonly #datasource: Datasource; readonly #endpointId: EndpointNumber; readonly commands: Record = {}; readonly skipCommandValidation?: boolean; constructor(type: ClusterTypeProtocol, backing: BehaviorBacking) { this.type = type; this.#datasource = backing.datasource; this.#endpointId = backing.endpoint.number; for (const cmd of type.commands) { const commandModels = this.#commandModels(backing, cmd); this.commands[cmd.id] = (args, session) => invokeCommand(backing, cmd, args, session, commandModels); } for (const cmd of type.commands) { if (maybeConfigOfConstructor(backing.type.prototype, camelize(cmd.name))) { this.skipCommandValidation = true; break; } } } #commandModels(backing: BehaviorBacking, cmd: CommandTypeProtocol): CommandModels | undefined { const { schema } = backing.type; if (!(schema instanceof ClusterModel)) { return; } const requestModel = schema.commands.find(c => c.id === cmd.id && c.isRequest); if (!requestModel) { logger.warn(`No schema for command ${cmd.name} (${cmd.id})`); return; } return { supervisor: backing.type.supervisor, requestModel, responseModel: requestModel.responseModel, }; } get version() { return this.#datasource.version; } get location() { return this.#datasource.location; } readState(session: InteractionSession): Val.ProtocolStruct { // A reference lives until its session's transaction closes, so a session without one would never release it if (session.transaction === undefined) { if (hasRemoteActor(session)) { throw new ImplementationError(`${this} state for a remote subject must be read with a transaction`); } return this.#datasource.view; } const { interactionComplete: _ic, ...readOnly } = LocalActorContext.ReadOnly; const supervisorSession: ValueSupervisor.Session = { ...readOnly, ...session, }; return this.#datasource.reference(supervisorSession); } async openForWrite(session: InteractionSession): Promise { if (session.transaction === undefined) { throw new ImplementationError("Cluster protocol must be opened with a supervisor session"); } await session.transaction.addResources(this.#datasource); await session.transaction.begin(); return this.#datasource.reference(session as ValueSupervisor.Session); } toString() { return `cluster-proto#${this.#endpointId}:${this.type.id}`; } inspect() { return this.toString(); } } const behaviorCache = new WeakMap>(); function clusterTypeProtocolOf(backing: BehaviorBacking): ClusterTypeProtocol | undefined { const behavior = backing.type; const { cluster, schema } = behavior as ClusterBehavior.Type; if (cluster === undefined || schema?.id === undefined) { return; } const supportedElements = backing.endpoint.behaviors.elementsOf(behavior); const nonMandatorySupportedAttributes = new Set(); const nonMandatorySupportedEvents = new Set(); const supportedCommands = new Set(); let wildcardPathFlags = schema.effectiveQuality.diagnostics ? WildcardPathFlags.skipDiagnosticsClusters : 0; if (schema.id & 0xffff0000) { wildcardPathFlags |= WildcardPathFlags.skipCustomElements; } const attrList = Array(); const attributes: CollectionProtocol = { [Symbol.iterator]: attrList[Symbol.iterator].bind(attrList), }; const eventList = Array(); const events: CollectionProtocol = { [Symbol.iterator]: eventList[Symbol.iterator].bind(eventList), }; const commandList = Array(); const commands: CollectionProtocol = { [Symbol.iterator]: commandList[Symbol.iterator].bind(commandList), }; // Collect all attributes and events from model and generate type protocol // TODO: Potentially combine the two searches again once the issue os fixed when selecting attributes and events for (const member of behavior.supervisor.membersOf(schema, { tags: [ElementTag.Attribute, ElementTag.Event, ElementTag.Command], })) { const { id, tag, effectiveQuality: quality } = member; if (id === undefined) { continue; } // Deprecated and disallowed elements are excluded and treated like unknown elements. A client node's peer // schema marks a deprecated element the device reports as optional, so it passes here. Obsolete elements // pass: a server never supports them, and a client may still see them. if (member.isDeprecated || member.isDisallowed) { continue; } const name = member.propertyName; switch (tag) { case "attribute": { if (!member.effectiveConformance.isMandatory && !supportedElements.attributes.has(name)) { continue; } const tlv = TlvOfModel(member); let wildcardPathFlags; switch (id) { case GeneratedCommandList.id: case AcceptedCommandList.id: wildcardPathFlags = WildcardPathFlags.skipGlobalAttributes | WildcardPathFlags.skipCommandLists; break; case AttributeList.id: wildcardPathFlags = WildcardPathFlags.skipGlobalAttributes | WildcardPathFlags.skipAttributeList; break; default: wildcardPathFlags = 0; break; } if (id & 0xffff0000) { wildcardPathFlags |= WildcardPathFlags.skipCustomElements; } if (quality.fixed) { wildcardPathFlags |= WildcardPathFlags.skipFixedAttributes; } if (quality.changesOmitted) { wildcardPathFlags |= WildcardPathFlags.skipChangesOmittedAttributes; } const { access: { limits }, } = behavior.supervisor.get(member); const { changesOmitted, effectiveQuality: { quieter }, } = member; const attr: AttributeTypeProtocol = { id: id as AttributeId, tlv, wildcardPathFlags, limits, name, }; if (changesOmitted) { attr.changesOmitted = true; } if (quieter) { attr.quieter = true; } attrList.push(attr); attributes[id] = attr; if (!member.effectiveConformance.isMandatory) { nonMandatorySupportedAttributes.add(id as AttributeId); } break; } case "event": { if (!member.effectiveConformance.isMandatory && !supportedElements.events.has(name)) { continue; } const tlv = TlvOfModel(member); const { access: { limits }, } = behavior.supervisor.get(member); const event = { id: id as EventId, tlv, limits, name }; eventList.push(event); events[id] = event; if (!member.effectiveConformance.isMandatory) { nonMandatorySupportedEvents.add(id as EventId); } break; } case "command": { // A mandatory command left unimplemented is absent from AcceptedCommandList; dispatching it anyway // would answer an invoke the cluster does not advertise if (id === CommandId.NONE || !supportedElements.commands.has(name) || !member.isRequest) { continue; } const requestTlv = TlvOfModel(member); const responseModel = (member as CommandModel).responseModel; const responseTlv = responseModel ? TlvOfModel(responseModel) : TlvVoid; const responseId = (responseModel?.id ?? id) as CommandId; const { access: { limits }, } = behavior.supervisor.get(member); const command = { id: id as CommandId, responseId, requestTlv, responseTlv, limits, name }; commandList.push(command); commands[id] = command; supportedCommands.add(id as CommandId); break; } } } const elementsCacheKey = `a:${[...nonMandatorySupportedAttributes.values()].sort().join(",")},e:${[...nonMandatorySupportedEvents.values()].sort().join(",")},c:${[...supportedCommands.values()].sort().join(",")}`; const existingCache = behaviorCache.get(behavior)?.get(elementsCacheKey); if (existingCache) { return existingCache; } const descriptor: ClusterTypeProtocol = { id: schema.id as ClusterId, name: schema.name, attributes, events, commands, wildcardPathFlags, }; const elementCache = behaviorCache.get(behavior) ?? new Map(); elementCache.set(elementsCacheKey, descriptor); behaviorCache.set(behavior, elementCache); return descriptor; } interface CommandModels { supervisor: RootSupervisor; requestModel: CommandModel; responseModel?: CommandModel; } function validateCommandPayload( data: Val.Struct | undefined, model: CommandModel, supervisor: RootSupervisor, session: InteractionSession, outerResolve?: (name: string) => Val, config?: Supervision.Config, ) { if (data === undefined) { return; } const validate = supervisor.get(model).validate; if (!validate) { return; } validate(data, session as ValueSupervisor.Session, { path: new DataModelPath(model.path), outerResolve, config, }); } /** * Invokes a command on a backing behavior */ function invokeCommand( backing: BehaviorBacking, command: CommandTypeProtocol, request: Val.Struct | undefined, session: InteractionSession, commandModels?: CommandModels, ): MaybePromise { if (session.transaction === undefined) { throw new ImplementationError("Cluster protocol must be opened with a supervisor session"); } let requestDiagnostic: unknown; if (isObject(request)) { requestDiagnostic = Diagnostic.dict(request); } else if (request !== undefined) { requestDiagnostic = request; } else { requestDiagnostic = Diagnostic.weak("(no payload)"); } const { path, endpoint } = backing; const context = session as ActionContext; logger.info( "Invoke", Mark.INBOUND, Diagnostic.strong(path.at(camelize(command.name))), session.transaction.via, requestDiagnostic, ); const commandInputConfig = maybeConfigOfConstructor(backing.type.prototype, camelize(command.name)); const onValidationError = commandInputConfig?.supervision?.onValidationError; if (commandModels) { try { validateCommandPayload( request, commandModels.requestModel, commandModels.supervisor, session, undefined, commandInputConfig, ); } catch (e) { if (onValidationError) { onValidationError(asError(e)); } else { logger.warn("Request validation error for", command.name, Diagnostic.errorMessage(asError(e))); } } } const outerResolve = commandModels && request ? (name: string) => (name === camelize(commandModels.requestModel.name) ? request : undefined) : undefined; const agent = endpoint.agentFor(context); const behavior = agent.get(backing.type); let isAsync = false; let activity: undefined | Disposable; let result: unknown; const { name } = command; try { activity = hasRemoteActor(context) ? context.activity?.frame(`invoke ${name}`) : undefined; const invoke = (behavior as unknown as Record unknown>)[camelize(name)].bind( behavior, ); // Lock if necessary, then invoke if ((behavior.constructor as ClusterBehavior.Type).lockOnInvoke) { const tx = session.transaction; if (Transaction.Resource.isLocked(behavior)) { // Automatic locking with locked resource; requires async lock acquisition result = (async function invokeAsync() { await tx.addResources(behavior); await tx.begin(); return invoke(request); })(); } else { // Automatic locking on unlocked resource; may proceed synchronously tx.addResourcesSync(behavior); tx.beginSync(); result = invoke(request); } } else { // Automatic locking disabled result = invoke(request); } if (MaybePromise.is(result)) { isAsync = true; result = Promise.resolve(result) .then(result => { if (commandModels?.responseModel) { try { validateCommandPayload( result as Val.Struct | undefined, commandModels.responseModel, commandModels.supervisor, session, outerResolve, ); } catch (e) { const error = new StatusResponse.FailureError( `Response validation failed for ${command.name}: ${asError(e).message}`, ); error.cause = e; throw error; } } if (isObject(result)) { logger.info( "Invoke", Mark.OUTBOUND, Diagnostic.strong(path.at(camelize(command.name))), session.transaction!.via, Diagnostic.dict(result), ); } return result; }) .finally(() => activity?.[Symbol.dispose]()); } else { if (commandModels?.responseModel) { try { validateCommandPayload( result as Val.Struct | undefined, commandModels.responseModel, commandModels.supervisor, session, outerResolve, ); } catch (e) { const error = new StatusResponse.FailureError( `Response validation failed for ${command.name}: ${asError(e).message}`, ); error.cause = e; throw error; } } if (isObject(result)) { logger.info( "Invoke", Mark.OUTBOUND, Diagnostic.strong(path.at(camelize(command.name))), session.transaction.via, Diagnostic.dict(result), ); } } } finally { if (!isAsync) { activity?.[Symbol.dispose](); } } return result as MaybePromise; } /** * Resolve a wire-format path into a {@link DataModelPath} for logging. * * Mirrors {@link ExpandedPath}'s segment shape (state./events./markers) but resolves cluster/element names against * the node-local protocol so custom clusters render with their type names rather than falling back to hex. * * Endpoint names from the node are intentionally not used — endpoints render by number — to keep client and server * logs symmetric. */ function resolvePathForNode(node: NodeProtocol, path: AttributePath | EventPath | CommandPath): DataModelPath { const { endpointId, clusterId } = path; const endpoint = endpointId !== undefined ? node[endpointId] : undefined; const cluster = endpoint && clusterId !== undefined ? endpoint[clusterId] : undefined; let dmPath = new DataModelPath(endpointId ?? "*", "endpoint"); dmPath = dmPath.at(nameOf(cluster?.type.name, clusterId), "cluster"); if ("attributeId" in path) { const attribute = cluster && path.attributeId !== undefined ? cluster.type.attributes[path.attributeId] : undefined; dmPath = dmPath.at("state").at(nameOf(attribute?.name, path.attributeId)); if (path.listIndex === null) { dmPath = dmPath.at("[ADD]", "marker"); } return dmPath; } if ("eventId" in path) { const event = cluster && path.eventId !== undefined ? cluster.type.events[path.eventId] : undefined; dmPath = dmPath.at("events").at(nameOf(event?.name, path.eventId)); if ("isUrgent" in path && path.isUrgent) { dmPath = dmPath.at("!", "marker"); } return dmPath; } if ("commandId" in path) { const command = cluster && path.commandId !== undefined ? cluster.type.commands[path.commandId] : undefined; return dmPath.at(nameOf(command?.name, path.commandId)); } // Wildcard path with no element discriminator — render as bare element wildcard return dmPath.at("*", "element"); function nameOf(name: string | undefined, id: number | undefined) { if (name !== undefined) { return camelize(name); } if (id === undefined) { return "*"; } return `0x${id.toString(16)}`; } }