/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js"; import type { Endpoint } from "#endpoint/Endpoint.js"; import { ChangeNotificationService } from "#node/integration/ChangeNotificationService.js"; import { AsyncObservable, BasicObservable, EventEmitter, ImplementationError, InternalError, Logger, MaybePromise, Observable, QuietObservable, Time, type Observer, } from "@matter/general"; import { ElementTag, EventElement, EventModel, type AttributeElement, type ValueModel } from "@matter/model"; import { NumberedOccurrence, Occurrence, OccurrenceManager, Val } from "@matter/protocol"; import { ClusterId, EventId, Priority } from "@matter/types"; import type { Behavior } from "./Behavior.js"; const logger = Logger.get("Events"); /** * Events emitted by a {@link Behavior}. */ export class Events extends EventEmitter { #endpoint?: Endpoint; #behavior?: Behavior.Type; #changes?: ChangeNotificationService; setContext(endpoint: Endpoint, behavior: Behavior.Type) { this.#endpoint = endpoint; this.#behavior = behavior; } /** * Emitted when state associated with this behavior is first mutated by a specific interaction. */ declare interactionBegin: Observable<[context?: ValueSupervisor.Session], MaybePromise>; /** * Emitted when a mutating interaction completes. */ declare interactionEnd: Observable<[context?: ValueSupervisor.Session], MaybePromise>; /** * Emitted when the state of this behavior changes at the end after all concrete $Changed events were emitted. */ declare stateChanged: Observable<[context?: ValueSupervisor.Session], MaybePromise>; static { for (const name of ["interactionBegin", "interactionEnd", "stateChanged"]) { Object.defineProperty(this.prototype, name, { get(this: EventEmitter) { if (this.hasEvent(name, true)) { return this.getEvent(name); } const event = Observable<[context?: ValueSupervisor.Session], MaybePromise>(); this.addEvent(name, event); return event; }, enumerable: true, }); } } get endpoint() { return this.#endpoint; } get behavior() { return this.#behavior; } get changes() { if ( this.#changes === undefined && this.#endpoint !== undefined && this.#endpoint.env.has(ChangeNotificationService) ) { this.#changes = this.#endpoint.env.get(ChangeNotificationService); } return this.#changes; } override toString() { return `${this.#endpoint ?? "?"}.${this.#behavior?.id ?? "?"}.events`; } } export namespace Events { export interface Context {} /** * Generic type for {@link Endpoint#events}. */ export interface Generic extends Record< string, undefined | Record > {} } /** * An event associated with a specific matter element such as an {@link AttributeElement} or {@link EventElement}. */ export class ElementEvent extends BasicObservable< T, MaybePromise > { #schema: S; #owner: Events; constructor(schema: S, owner: Events) { super(); this.#schema = schema; this.#owner = owner; } /** * The element that triggers this event. */ get schema() { return this.#schema; } protected get owner() { return this.#owner; } } /** * An {@link ElementEvent} that emits locally. * * Observers that throw errors will terminate the emitter. */ export class OfflineEvent extends ElementEvent { constructor(schema: S, owner: Events) { super(schema, owner); } } /** * An {@link ElementEvent} that transmits to other Matter nodes. * * Online events emit externally. Observers run sequentially and if async register as node activity but otherwise have * no affect on the emitter. */ export class OnlineEvent extends ElementEvent { readonly isQuieter: boolean = false; #baseOccurrence: Omit | undefined; #occurrenceTrigger?: (payload: any) => void; protected override handleError(error: Error, observer: Observer) { logger.warn(`Error in ${descriptionOf(this, observer)}`, error); } constructor(schema: S, owner: Events) { super(schema, owner); // If it is a "real" Matter event, then we connect this event instance with the OccurrenceManager and // ChangeNotificationService const eventSchema = this.schema as EventModel; const clusterId = this.owner.behavior?.schema.id; if ( this.schema.tag === ElementTag.Event && this.schema.id !== undefined && this.owner.endpoint !== undefined && clusterId !== undefined && eventSchema.priority !== undefined ) { this.#connectWithOccurenceManager(this.schema.id, clusterId, eventSchema.priority); } } #connectWithOccurenceManager(eventId: number, clusterId: number, priority: EventElement.Priority) { if (this.owner.endpoint === undefined) { throw new InternalError("Events initialized with no assigned endpoint"); } // Obtain occurrence manager. Events on client nodes will not have one, and their IDs are the peer's to choose const occurrenceManager = this.owner.endpoint.env.maybeGet(OccurrenceManager); if (occurrenceManager === undefined) { return; } this.#baseOccurrence = { eventId: EventId(eventId), clusterId: ClusterId(clusterId), endpointId: this.owner.endpoint.number, priority: EventElement.PriorityId[priority] as unknown as Priority, }; const trigger = (payload?: any) => { const occurrence = occurrenceManager.add({ ...this.#baseOccurrence!, epochTimestamp: Time.nowMs, payload, }); if (MaybePromise.is(occurrence)) { this.owner.endpoint!.env.runtime.add(occurrence.then(this.#broadcast.bind(this))); } else { this.#broadcast(occurrence); } }; this.online.on(trigger as unknown as Observer); this.#occurrenceTrigger = trigger; } #broadcast({ number, epochTimestamp: timestamp, priority, payload }: NumberedOccurrence) { this.owner.changes?.broadcastEvent(this.owner.endpoint!, this.owner.behavior!, this.schema as EventModel, { number, timestamp, timestampKind: "epoch", priority, payload: payload as Val.Struct | undefined, }); } /** * An {@link Observable} that emits only those events conveyed between nodes. * * Normally this is the {@link OnlineEvent}, but in the case of server-side elements that are * {@link Quality.quieter} this is {@link quiet}. */ get online(): AsyncObservable { return this; } /** * A quieter version of the {@link OnlineEvent}. * * Throws if the node is not a server or the associated element is not {@link Quality.quieter}. * * By default emits latest changes once per second but you can reconfigure via {@link QuietObservable} properties * and/or trigger emits using {@link QuietObservable.emitNow} and {@link QuietObservable.emitSoon}. */ get quiet(): QuietObservable> { throw new ImplementationError(`Matter does not define ${this} with "quieter" (Q) quality`); } override toString() { const base = `${this.owner.toString()}.${this.schema.propertyName}`; if (this.schema.tag === ElementTag.Attribute || this.schema.tag === ElementTag.Field) { return `${base}$Changed`; } return base; } override [Symbol.dispose]() { if (this.#occurrenceTrigger) { this.online.off(this.#occurrenceTrigger as unknown as Observer); this.#occurrenceTrigger = undefined; } } } /** * An {@link OnlineEvent} for elements marked with {@link Quality#quieter}. * * Quiet events provide a second observable for {@link online} that implements configurable rate limiting. */ export class QuietEvent extends OnlineEvent { override readonly isQuieter = true; #quiet: QuietObservable>; constructor(schema: S, owner: Events, config?: QuietObservable.Configuration) { super(schema, owner); this.#quiet = new QuietObservable({ shouldEmit(...args: T) { const [newValue, oldValue] = args; return oldValue === null || (newValue === null && oldValue !== newValue) ? "now" : true; }, ...config, source: this, }); } override get online(): AsyncObservable { return this.#quiet; } override get quiet() { return this.#quiet; } override [Symbol.dispose]() { this.#quiet[Symbol.dispose](); super[Symbol.dispose](); } } function descriptionOf(observable: AsyncObservable, observer: Observer) { let desc = `${observable} observer`; if (observer.name) { desc = `${desc} ${observer.name}`; } return desc; }