/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import type { Node } from "#node/Node.js"; import { AsyncObservable, CrashedDependencyError, DestroyedDependencyError, Diagnostic, ImplementationError, Lifecycle, Logger, Observable, } from "@matter/general"; import type { Endpoint } from "../Endpoint.js"; const logger = Logger.get("PartLifecycle"); /** * State related to a {@link Endpoint}'s lifecycle. */ export class EndpointLifecycle { #endpoint: Endpoint; #isEssential: boolean; #isInstalled = false; #isReady = false; #isPartsReady = false; #destructionBegun = false; #hasId = false; #hasNumber = false; #installed = Observable<[]>(error => this.emitError("installed", error)); #ready = Observable<[]>(error => this.emitError("ready", error)); #partsReady = Observable<[]>(error => this.emitError("partsReady", error)); #destroying = Observable<[]>(error => this.emitError("destroying", error)); #destroyed = Observable<[]>(error => this.emitError("destroyed", error)); #changed = Observable<[type: EndpointLifecycle.Change, endpoint: Endpoint]>(error => this.emitError("changed", error), ); #reset = AsyncObservable<[]>(); #configurationVersionChanged = Observable<[]>(error => this.emitError("configurationVersionChanged", error)); #queuedUpdates?: Array; /** * Emitted when an endpoint is installed into an initialized owner. */ get installed() { return this.#installed; } /** * Emitted when an endpoint is fully initialized excepting children. */ get ready() { return this.#ready; } /** * Emitted when an endpoint is fully initialized including children. */ get partsReady() { return this.#partsReady; } /** * @deprecated use {@link partsReady} */ get treeReady() { return this.partsReady; } /** * Emitted when the endpoint begins destruction. */ get destroying() { return this.#destroying; } /** * Emitted when the endpoint is destroyed. */ get destroyed() { return this.#destroyed; } /** * Bubbling event indicating changes to endpoint structure. */ get changed() { return this.#changed; } /** * Emitted on factory reset. */ get reset() { return this.#reset; } /** * Emitted when the endpoint's `ConfigurationVersion` changes — from `BasicInformation` on a node's root endpoint or * `BridgedDeviceBasicInformation` on a bridged endpoint. Wired for client peers by {@link Peers}. */ get configurationVersionChanged() { return this.#configurationVersionChanged; } /** * Is the {@link Endpoint} installed in a {@link Node}? */ get isInstalled() { return this.#isInstalled; } /** * Is the {@link Endpoint} fully initialized, excepting children? */ get isReady() { return this.#isReady; } /** * Is the {@link Endpoint} fully initialized, including children? */ get isPartsReady() { return this.#isPartsReady; } /** * Can the {@link Endpoint}'s state be read right now? * * Only between completed construction and the start of a reset or of destruction. An initializing endpoint has * nothing to answer with yet, and a resetting, crashed or closing one has nothing left. */ get isReadable() { return this.#isReady && this.#endpoint.construction.status === Lifecycle.Status.Active; } /** * Has the {@link Endpoint} begun closing or deleting, or has its construction crashed? A closing or deleted endpoint * does not become readable again; a crashed one may be restarted. Unlike "not {@link isReadable}", this is false * while the endpoint initializes or while a reset runs outside of a deletion. */ get isGone() { if (this.#destructionBegun) { return true; } const status = this.#endpoint.construction.status; return ( status === Lifecycle.Status.Destroying || status === Lifecycle.Status.Destroyed || status === Lifecycle.Status.Crashed ); } /** * Throw if the {@link Endpoint} {@link isGone}: {@link CrashedDependencyError} if its construction crashed, * {@link DestroyedDependencyError} once it began closing or deleting. */ assertNotGone() { if (!this.isGone) { return; } const what = this.#endpoint.toString(); const { construction } = this.#endpoint; if (construction.status === Lifecycle.Status.Crashed) { const error = new CrashedDependencyError(what, "crashed"); error.subject = this.#endpoint; error.cause = construction.error; throw error; } throw new DestroyedDependencyError(what, "is closing or being deleted"); } /** * @deprecated use {@link isPartsReady} */ get isTreeReady() { return this.isPartsReady; } /** * Designates endpoint as essential. * * By default endpoints are considered "essential". An essential endpoint must initialize successfully or an error * is thrown. Non-essential endpoints may be installed even if they have errors. * * You may configure this property via {@link Endpoint.Configuration#isEssential}. */ get isEssential() { return this.#isEssential; } /** * Does the endpoint have an ID? */ get hasId() { return this.#hasId; } /** * Does the endpoint have an endpoint number? */ get hasNumber() { return this.#hasNumber; } constructor(endpoint: Endpoint, isEssential?: boolean) { this.#endpoint = endpoint; this.#isEssential = isEssential ?? true; // Bubble crash events endpoint.construction.change.on(status => { if (status === Lifecycle.Status.Crashed) { this.change(EndpointLifecycle.Change.Crashed); } }); } /** * Bubble a lifecycle change event from a child. */ bubble(type: EndpointLifecycle.Change, endpoint: Endpoint) { this.#changed.emit(type, endpoint); } /** * Inform the Lifecycle of a change in lifecycle. */ change(type: EndpointLifecycle.Change) { // Update state switch (type) { case EndpointLifecycle.Change.Installed: this.#isInstalled = true; break; case EndpointLifecycle.Change.Destroying: this.#isReady = false; this.#destructionBegun = true; break; case EndpointLifecycle.Change.Ready: // Sanity checks if (!this.#hasId) { throw new ImplementationError("Endpoint reports as ready but has no ID assigned"); } if (!this.#hasNumber) { throw new ImplementationError("Endpoint reports as ready but has no number assigned"); } this.#isReady = true; break; case EndpointLifecycle.Change.PartsReady: // Sanity checks if (!this.#isReady) { throw new ImplementationError("Endpoint reports as parts-ready but is not itself ready"); } this.#isPartsReady = true; break; case EndpointLifecycle.Change.IdAssigned: this.#hasId = true; break; case EndpointLifecycle.Change.NumberAssigned: this.#hasNumber = true; break; } // We want events to trigger in correct order but some of them cascade. // So if we're currently emitting, enqueue if (this.#queuedUpdates) { this.#queuedUpdates?.push(type); return; } // Emit events this.#queuedUpdates = [type]; try { while (this.#queuedUpdates.length) { const type = this.#queuedUpdates[0]; this.#queuedUpdates.shift(); // Emit change event this.#changed.emit(type, this.#endpoint); // Emit endpoint-specific events const observable = (this as unknown as Record)[type]; if (observable !== undefined) { observable.emit(); } } } finally { this.#queuedUpdates = undefined; } } protected emitError(name: string, error: Error) { logger.error("Unhandled error in", Diagnostic.strong(`${this.#endpoint}.lifecycle.${name}`), "handler:", error); } /** * Revert to uninstalled state. */ resetting() { this.#isInstalled = this.#isReady = this.#isPartsReady = false; } } export namespace EndpointLifecycle { export enum Change { Installed = "installed", Ready = "ready", PartsReady = "partsReady", Crashed = "crashed", Destroying = "destroying", Destroyed = "destroyed", ServersChanged = "serversChanged", ClientsChanged = "clientsChanged", IdAssigned = "idAssigned", NumberAssigned = "numberAssigned", } }