/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { ClusterBehavior } from "#behavior/cluster/ClusterBehavior.js"; import { GlobalAttributeState } from "#behavior/cluster/ClusterState.js"; import { ValidatedElements } from "#behavior/cluster/ValidatedElements.js"; import { OnlineEvent } from "#behavior/Events.js"; import type { Endpoint } from "#endpoint/Endpoint.js"; import type { Agent } from "#endpoint/index.js"; import type { SupportedElements } from "#endpoint/properties/Behaviors.js"; import { DeviceTypeConformanceService } from "#node/server/DeviceTypeConformanceService.js"; import { camelize, ImplementationError, MaybePromise, ObserverGroup } from "@matter/general"; import { ClusterModel, CommandModel, FeatureSet, FieldValue, Schema } from "@matter/model"; import { Val } from "@matter/protocol"; import { AttributeId, CommandId } from "@matter/types"; import { Behavior } from "../Behavior.js"; import { Datasource } from "../state/managed/Datasource.js"; import { BehaviorBacking } from "./BehaviorBacking.js"; const NoElements = new Set(); export class FeatureMismatchError extends ImplementationError {} /** * This class backs the server implementation of a behavior. */ export class ServerBehaviorBacking extends BehaviorBacking { #elements?: SupportedElements; #suppressedChanges?: Map; #quietObservers?: ObserverGroup; constructor(endpoint: Endpoint, type: Behavior.Type, store: Datasource.Store, options?: Behavior.Options) { super(endpoint, type, store, options); this.#configureEventSuppression(); } get elements() { return this.#elements; } protected override invokeInitializer(behavior: Behavior, options?: Behavior.Options) { const finalizeState = () => { this.#applyTransitiveDefaults(behavior.state); if (behavior instanceof ClusterBehavior) { this.#configureElements(behavior); } else { this.#elements = { features: NoElements, attributes: NoElements, commands: NoElements, events: NoElements, }; } // State must now conform to the schema. Validate the behavior's state rather than internal state // because the behavior likely has uncommitted changes const context = behavior.context; this.datasource.validate(context, behavior.state); }; const promise = super.invokeInitializer(behavior, options); if (promise) { return promise.then(finalizeState); } finalizeState(); } /** * Schema may specify that state fields default to the value of another field. We apply these defaults after * initialization when the other field should be defined. */ #applyTransitiveDefaults(state: Val.Struct) { const schema = this.type.schema; if (!schema) { return; } for (const member of this.type.supervisor.membersOf(schema)) { const name = member.propertyName; if (state[name] === undefined) { const referenced = FieldValue.referenced(member.default); if (referenced) { const val = state[camelize(referenced)]; if (val !== undefined) { state[name] = val; } } } } } #configureElements(behavior: ClusterBehavior) { // Validate const validation = new ValidatedElements(behavior.constructor as ClusterBehavior.Type, behavior, { strict: this.endpoint.env.maybeGet(DeviceTypeConformanceService)?.mode === "strict", }); validation.report(); const globals = behavior.state as GlobalAttributeState; // Update attribute list from validated elements (includes global attributes via scope) globals.attributeList = ([...validation.attributeIds.values()] as AttributeId[]).sort((a, b) => a - b); // Update accepted & generated command lists from the schema (ClusterModel) const schema = Schema(behavior.type) as ClusterModel; const commandModels = new Map(); for (const cmd of schema.commands) { if (cmd.isRequest) { commandModels.set(cmd.propertyName, cmd); } } // Build response model index for responseId/responseSchema lookup const responseModels = new Map(); for (const cmd of schema.commands) { if (!cmd.isRequest) { responseModels.set(cmd.name, cmd); } } const acceptedIds = [] as CommandId[]; const generatedIds = new Set(); for (const name of validation.commands) { const model = commandModels.get(name); if (!model || model.id === (CommandId.NONE as number)) { continue; } acceptedIds.push(model.id as CommandId); // Find the response command if any const responseName = model.effectiveResponse; if (responseName && responseName !== "status") { const responseModel = responseModels.get(responseName); if (responseModel && responseModel.id !== (CommandId.NONE as number)) { generatedIds.add(responseModel.id as CommandId); } } } globals.acceptedCommandList = acceptedIds.sort((a, b) => a - b); globals.generatedCommandList = [...generatedIds].sort((a, b) => a - b); // Validate the feature map if (schema.tag === "cluster") { const { supportedFeatures, featureMap } = Schema(behavior.type) as ClusterModel; const { featuresSupported, featuresAvailable } = FeatureSet.normalize( featureMap, new FeatureSet(globals.featureMap), ); for (const name of featuresAvailable) { if (supportedFeatures.has(name) !== featuresSupported.has(name)) { throw new FeatureMismatchError( `The featureMap for ${behavior} does not match the implementation; please use ${behavior.type.name}.with("FeatureName") to configure features`, ); } } } // Load public API this.#elements = { features: behavior.type.schema.supportedFeatures ?? new Set(), attributes: validation.attributes, commands: validation.commands, events: validation.events, }; } #onChange(props: string[]) { if (this.#suppressedChanges) { props = props.filter(name => !this.#suppressedChanges!.has(name)); } this.broadcastChanges(props); } /** * Report attributes this behavior computes on read. Change detection diffs stored values, so it never sees such * an attribute change; the behavior says so instead. */ override markChanged(props: string[]) { if (!props.length) { throw new ImplementationError(`${this} cannot report a change without naming an attribute`); } if (this.#elements === undefined) { throw new ImplementationError(`${this} cannot report a change before it is initialized`); } for (const name of props) { if (!this.#elements.attributes.has(name)) { throw new ImplementationError(`${this} cannot report "${name}", which is not an attribute it supports`); } // Refuse rather than ignore, so a behavior naming one of these learns it has the wrong mechanism switch (this.#suppressedChanges?.get(name)) { case "changesOmitted": throw new ImplementationError( `${this} cannot report "${name}"; the specification omits change reporting for it`, ); case "quieter": throw new ImplementationError( `${this} cannot report "${name}" directly; emit its change event so its throttle applies`, ); } } // The framework never saw this change, so nothing has moved the version for it this.datasource.advanceVersion(); this.broadcastChanges(props); } protected override get datasourceOptions(): Datasource.Options { const options = super.datasourceOptions; options.onChange = this.#onChange.bind(this); return options; } override close(agent?: Agent): MaybePromise { this.#quietObservers?.close(); return super.close(agent); } /** * We handle events in bulk via {@link Datasource.Options.onChange}, but "quieter" and "changesOmitted" events * require special handling. Those we ignore in the change handler and instead report only when emitted by the * corresponding {@link OnlineEvent}. */ #configureEventSuppression() { const { schema } = this.type; if (!schema) { return; } for (const property of schema.conformant.properties) { const { changesOmitted, quieter } = property.effectiveQuality; if (!changesOmitted && !quieter) { continue; } const name = property.propertyName; if (!this.#suppressedChanges) { this.#suppressedChanges = new Map(); } this.#suppressedChanges.set(name, changesOmitted ? "changesOmitted" : "quieter"); if (!quieter) { continue; } const event = (this.events as unknown as Record)[`${name}$Changed`]; if (event === undefined) { continue; } if (event.isQuieter) { if (!this.#quietObservers) { this.#quietObservers = new ObserverGroup(); } this.#quietObservers.on(event.quiet, () => { this.datasource.advanceVersionFor([name]); this.broadcastChanges([name]); }); } } } }