/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { isClientBehavior } from "#behavior/cluster/cluster-behavior-utils.js"; import type { Agent } from "#endpoint/Agent.js"; import type { Endpoint } from "#endpoint/Endpoint.js"; import { BehaviorInitializationError } from "#endpoint/errors.js"; import type { SupportedElements } from "#endpoint/properties/Behaviors.js"; import { ChangeNotificationService } from "#node/integration/ChangeNotificationService.js"; import { ProtocolService } from "#node/integration/ProtocolService.js"; import { Construction, Duration, Entropy, EventEmitter, ImplementationError, InternalError, Lifecycle, Lifetime, Logger, MaybePromise, Observable, Timer, } from "@matter/general"; import type { ClusterId } from "@matter/types"; import { Behavior } from "../Behavior.js"; import { Reactor } from "../Reactor.js"; import { Datasource } from "../state/managed/Datasource.js"; import { BackingEvents } from "./BackingEvents.js"; import { BehaviorTimer } from "./BehaviorTimer.js"; import { Reactors } from "./Reactors.js"; const logger = Logger.get("BehaviorBacking"); /** * The "backing" for a behavior manages those portions of behavior that endure for the lifetime of an endpoint. */ export abstract class BehaviorBacking { #endpoint: Endpoint; #type: Behavior.Type; #protocol: ProtocolService; #changeTracking: ChangeNotificationService; #internal?: object; #events?: EventEmitter; #options?: Behavior.Options; #datasource?: Datasource; #reactors?: Reactors; #timers = new Set(); #closing = false; #construction: Construction; get construction() { return this.#construction; } constructor(endpoint: Endpoint, type: Behavior.Type, store: Datasource.Store, options?: Behavior.Options) { this.#endpoint = endpoint; this.#type = type; this.#protocol = endpoint.env.get(ProtocolService); this.#changeTracking = endpoint.env.get(ChangeNotificationService); this.store = store; this.#options = options; this.#construction = Construction(this); this.#construction.onError(error => { // The endpoint reports errors during initialization. For errors occurring later we report the error // ourselves if (endpoint.lifecycle.isReady) { if (error instanceof BehaviorInitializationError) { logger.error(error); } else { logger.error(`Error initializing ${this}:`, error); } } }); } toString() { return this.path.toString(); } get path() { return this.#endpoint.path.at(this.type.id); } /** * Initialize state by applying values from options and invoking the behavior's initialize() function. * * Initiated via {@link Construction#start} by Behaviors class once the backing is installed. */ [Construction.construct](agent: Agent) { let crashError: undefined | BehaviorInitializationError; const crash = (cause: unknown) => { if (!crashError) { crashError = new BehaviorInitializationError(`Error initializing ${this}`, cause); } throw crashError; }; try { // We use this behavior for initialization. Do not use agent.get() to access the behavior because it // will throw if the behavior isn't initialized const behavior = this.#lifecycleInstance(agent); // Perform actual initialization const promise = this.invokeInitializer(behavior, this.#options); return MaybePromise.then(promise, () => this.#protocol.addCluster(this), crash); } catch (e) { crash(e); } } get [Lifetime.owner]() { return this.#endpoint.construction; } initializeDataSource() { if (this.#datasource) { return; } this.#datasource = Datasource(this.datasourceOptions); } /** * Destroy the backing. */ close(agent?: Agent) { const initialized = this.construction.status === Lifecycle.Status.Active; if (!initialized) { agent = undefined; } return this.construction.close(() => { this.#closing = true; for (const timer of this.#timers) { timer.stop(); } let result = MaybePromise.then( () => this.#reactors?.close(), () => { this.#reactors = undefined; }, ); if (agent) { result = MaybePromise.then(result, () => this.#invokeClose(agent)); } this.#datasource?.close(); this.#datasource = undefined; return result; }); } /** * Set state from options and invoke {@link Behavior.invokeInitializer}. * * This is an optional extension point for derivatives. Errors thrown here are recorded and place the behavior into * crashed state. */ protected invokeInitializer(behavior: Behavior, options?: Behavior.Options) { return behavior.initialize(options); } /** * The {@link Endpoint} that owns the behavior. */ get endpoint() { return this.#endpoint; } /** * The {@link Behavior.Type} backed. */ get type() { return this.#type; } set type(type: Behavior.Type) { if (type.supports(this.#type)) { this.#type = type; return; } // Peer-only: a changed data version already forced a full re-read, so the new type and its values are // current and the change reaches consumers via ServersChanged. Informational, not a compatibility fault. logger.info(`Updated behavior ${this} to match changed peer cluster structure`); this.#type = type; this.refreshForChangedType(); } /** * Rebuild state derived from {@link type} after the backed cluster structure changes. Derivatives that cache * additional type-derived data override to refresh it. */ protected refreshForChangedType() { const datasource = this.#datasource; if (datasource === undefined) { return; } // Server stores can't reseed a rebuilt datasource, so recreating would reset their state to defaults; only // externally-mutable (peer) stores reclaim their live values. const store = this.store; if (!Datasource.ExternallyMutableStore.is(store) || typeof store.reclaimValues !== "function") { return; } // Move live values back to the store so the rebuilt datasource re-seeds from current data, not defaults. store.reclaimValues(); datasource.close(); this.#datasource = Datasource(this.datasourceOptions); } /** * Create an instance of the backed {@link Behavior}. * * Derivatives may override to perform additional setup beyond simple instantiation. */ createBehavior(agent: Agent, type: Behavior.Type) { const behavior = new this.#type(agent, this); if (behavior instanceof type || isClientBehavior(type)) { return behavior; } throw new ImplementationError( `Cannot create ${this.#endpoint}.${type.id} because installed implementation is incompatible`, ); } /** * The source of raw data that backs managed state instances. */ get datasource() { if (!this.#datasource) { throw new InternalError("Datasource not yet initialized"); } return this.#datasource; } /** * Access the datasource if present. */ get maybeDatasource() { return this.#datasource; } protected get datasourceOptions(): Datasource.Options { return { entropy: this.#endpoint.env.get(Entropy), location: { path: this.#endpoint.path.at(this.#type.id).at("state"), endpoint: this.#endpoint.number, cluster: this.type.schema.tag === "cluster" ? (this.type.schema.id as ClusterId) : undefined, }, supervisor: this.type.supervisor, type: this.type.State, events: this.events as unknown as Datasource.Events, defaults: this.#endpoint.behaviors.defaultsFor(this.type), store: this.store, migrate: this.#type.migrate === Behavior.migrate ? undefined : values => this.#type.migrate(values), owner: this.#endpoint, onChange: this.broadcastChanges.bind(this), }; } /** * The data provider for {@link datasource}. */ protected readonly store: Datasource.Store; /** * Obtain internal state for a behavior instance. */ getInternal() { if (!this.#internal) { this.#internal = new this.#type.Internal(); } return this.#internal; } /** * Access the event object. Unlike state, the events object does not vary by instance. */ get events() { if (!this.#events) { this.#events = BackingEvents(this); } return this.#events; } /** * The status of the behavior. */ get status() { return this.construction.status; } /** * A read-only offline view of behavior state. */ get stateView() { return this.datasource.view ?? {}; } /** * Supported elements. */ abstract readonly elements: SupportedElements | undefined; /** * Install a reactor. */ reactTo>( observable: O, reactor: Reactor, ReturnType>, options?: Reactor.Options, ) { if (!this.#reactors) { this.#reactors = new Reactors(this); } this.#reactors.add(observable, reactor, options); } /** * Create a timer that reacts like a reactor of this behavior; see {@link Behavior.reactorTimer}. */ createTimer( name: string, interval: Duration, periodic: boolean, reactor: Reactor<[], unknown>, options?: Behavior.ReactorTimerOptions, ): Timer { return new BehaviorTimer(this, name, interval, periodic, reactor, options); } /** * Track a timer that starts so {@link close} stops it. * * @returns false once the backing closes, in which case the timer must not start */ addRunningTimer(timer: BehaviorTimer) { if (this.#closing) { return false; } this.#timers.add(timer); return true; } deleteRunningTimer(timer: BehaviorTimer) { this.#timers.delete(timer); } /** * Stop reactors of {@link observable} from receiving further emissions without waiting for an ongoing reaction. */ detachReactors(observable: Observable) { this.#reactors?.detach({ observable }); } /** * Terminate reactions. */ async stopReacting(selector?: { observable?: Observable; reactor?: Reactor }) { if (this.#reactors === undefined) { return; } if (selector?.observable === undefined && selector?.reactor === undefined) { await this.#reactors.close(); this.#reactors = undefined; return; } await this.#reactors.remove(selector); } /** * Invoke {@link Behavior.destroy} to clean up application logic. */ #invokeClose(agent: Agent): MaybePromise { // Do not use Agent.get because backing is in "destroying" state const behavior = this.#lifecycleInstance(agent); return MaybePromise.finally( () => MaybePromise.then( () => behavior?.[Symbol.asyncDispose](), undefined, e => logger.warn(`Destroying ${this}:`, e), ), () => this.#events?.[Symbol.dispose](), ); } /** * Obtain a behavior instance for lifecycle methods (initialize and close). * * Under these circumstances we can't use {@link Agent.get} because it will throw if the endpoint is initializing * or closing. * * Instead we use a "friend" method of agent to retrieve any existing behavior or create a new one. */ #lifecycleInstance(agent: Agent) { if (agent.isLoaded(this.#type)) { return agent.get(this.#type); } const behavior = this.createBehavior(agent, this.#type); return behavior; } /** * We provide two forms of optimized change tracking. * * {@link ProtocolService} offers a low-level service that supports the Matter protocol. * * {@link ChangeNotificationService} is a higher-level service that supports state synchronization use cases. * * This method informs these services of changes. */ protected broadcastChanges(props: string[]) { this.#protocol.handleChange(this, props); this.#changeTracking.broadcastUpdate(this, props); } /** * Report attributes a behavior computes on read. Only a server behavior reports to subscribers, and only a server * behavior owns its data version - a peer's belongs to the peer. */ markChanged(props: string[]): void { throw new ImplementationError( `${this} cannot report ${props.map(name => `"${name}"`).join(", ")}; only a server behavior reports changes`, ); } }