@project-chip/matter.js
Version:
Matter protocol in pure js
1,123 lines (1,003 loc) • 63 kB
text/typescript
/**
* @license
* Copyright 2022-2025 Matter.js Authors
* SPDX-License-Identifier: Apache-2.0
*/
import { AdministratorCommissioning, BasicInformation, DescriptorCluster, OperationalCredentials } from "#clusters";
import {
AsyncObservable,
AtLeastOne,
BasicSet,
Construction,
Crypto,
Diagnostic,
ImplementationError,
InternalError,
Logger,
MatterError,
Observable,
Time,
Timer,
} from "#general";
import {
AttributeClientValues,
ChannelStatusResponseError,
ClusterClient,
ClusterClientObj,
DecodedAttributeReportValue,
DecodedEventReportValue,
InteractionClient,
NodeDiscoveryType,
NodeSession,
PaseClient,
UnknownNodeError,
structureReadAttributeDataToClusterObject,
} from "#protocol";
import {
AttributeId,
Attributes,
ClusterId,
ClusterType,
CommissioningFlowType,
DiscoveryCapabilitiesSchema,
EndpointNumber,
EventId,
ManualPairingCodeCodec,
NodeId,
QrPairingCodeCodec,
StatusCode,
StatusResponseError,
getClusterById,
} from "#types";
import { AcceptedCommandList, AttributeList, ClusterRevision, FeatureMap } from "@matter/model";
import { ClusterServer } from "../cluster/server/ClusterServer.js";
import { AttributeInitialValues, ClusterServerObj, isClusterServer } from "../cluster/server/ClusterServerTypes.js";
import { CommissioningController } from "../CommissioningController.js";
import { Aggregator } from "./Aggregator.js";
import { ComposedDevice } from "./ComposedDevice.js";
import { PairedDevice, RootEndpoint } from "./Device.js";
import { DeviceInformation, DeviceInformationData } from "./DeviceInformation.js";
import {
DeviceTypeDefinition,
DeviceTypes,
UnknownDeviceType,
getDeviceTypeDefinitionFromModelByCode,
} from "./DeviceTypes.js";
import { Endpoint } from "./Endpoint.js";
import { asClusterClientInternal, isClusterClient } from "./TypeHelpers.js";
const logger = Logger.get("PairedNode");
/** Delay after receiving a changed partList from a device to update the device structure */
const STRUCTURE_UPDATE_TIMEOUT_MS = 5_000; // 5 seconds
/** Delay after a disconnect to try to reconnect to the device */
const RECONNECT_DELAY_MS = 15_000; // 15 seconds
/** Delay after a shutdown event to try to reconnect to the device */
const RECONNECT_DELAY_AFTER_SHUTDOWN_MS = 30_000; // 30 seconds, to give device time to restart and maybe inform us about
/** Maximum delay after a disconnect to try to reconnect to the device */
const RECONNECT_MAX_DELAY_MS = 600_000; // 10 minutes
/**
* Delay after a new session was opened by the device while in discovery state.
* This usually happens for devices that support persisted subscriptions.
*/
const NEW_SESSION_WHILE_DISCOVERY_RECONNECT_DELAY_MS = 5_000;
export enum NodeStates {
/**
* Node seems active nd last communications were successful and subscription updates were received and all data is
* up-to-date.
*/
Connected = 0,
/**
* Node is disconnected. This means that the node was not connected so far or the developer disconnected it by API
* call or the node is removed. A real disconnection can not be detected because the main Matter protocol uses UDP.
* Data are stale and interactions will most likely return an error.
*/
Disconnected = 1,
/**
* Node is reconnecting. This means that former communications failed, and we are trying to reach the device on
* known addresses. Data are stale. It is yet unknown if the reconnection is successful. */
Reconnecting = 2,
/**
* The node seems offline because communication was not possible or is just initialized. The controller is now
* waiting for a MDNS announcement and tries every 10 minutes to reconnect.
*/
WaitingForDeviceDiscovery = 3,
}
/** @deprecated */
export enum NodeStateInformation {
/**
* Node seems active nd last communications were successful and subscription updates were received and all data is
* up-to-date.
*/
Connected = 0,
/**
* Node is disconnected. This means that the node was not connected so far or the developer disconnected it by API
* call or the node is removed. A real disconnection can not be detected because the main Matter protocol uses UDP.
* Data are stale and interactions will most likely return an error.
*/
Disconnected = 1,
/**
* Node is reconnecting. This means that former communications failed, and we are trying to reach the device on
* known addresses. Data are stale. It is yet unknown if the reconnection is successful. */
Reconnecting = 2,
/**
* The node seems offline because communication was not possible or is just initialized. The controller is now
* waiting for a MDNS announcement and tries every 10 minutes to reconnect.
*/
WaitingForDeviceDiscovery = 3,
/**
* Node structure has changed (Endpoints got added or also removed). Data are up-to-date.
* This State information will only be fired when the subscribeAllAttributesAndEvents option is set to true.
*/
StructureChanged = 4,
/**
* The node was just Decommissioned. This is a final state.
*/
Decommissioned = 5,
}
export type CommissioningControllerNodeOptions = {
/**
* Unless set to false the node will be automatically connected when initialized. When set to false use
* connect() to connect to the node at a later timepoint.
*/
readonly autoConnect?: boolean;
/**
* Unless set to false all events and attributes are subscribed and value changes are reflected in the ClusterClient
* instances. With this reading attributes values is mostly looked up in the locally cached data.
* Additionally more features like reaction on shutdown event or endpoint structure changes (for bridges) are done
* internally automatically.
*/
readonly autoSubscribe?: boolean;
/**
* Minimum subscription interval when values are changed. Default it is set to 1s.
* If the device is intermittently connected, the minimum interval is always set to 0s because required by Matter specs.
*/
readonly subscribeMinIntervalFloorSeconds?: number;
/**
* Maximum subscription interval when values are changed. This is also used as a keepalive mechanism to validate
* that the device is still available. matter.js tries to set meaningful values based on the device type, connection
* type and other details. So ideally do not set this parameter unless you know it better.
*/
readonly subscribeMaxIntervalCeilingSeconds?: number;
/**
* Optional additional callback method which is called for each Attribute change reported by the device. Use this
* if subscribing to all relevant attributes is too much effort.
* @deprecated Please use the events.attributeChanged observable instead.
*/
readonly attributeChangedCallback?: (nodeId: NodeId, data: DecodedAttributeReportValue<any>) => void;
/**
* Optional additional callback method which is called for each Event reported by the device. Use this if
* subscribing to all relevant events is too much effort.
* @deprecated Please use the events.eventTriggered observable instead.
*/
readonly eventTriggeredCallback?: (nodeId: NodeId, data: DecodedEventReportValue<any>) => void;
/**
* Optional callback method which is called when the state of the node changes. This can be used to detect when
* the node goes offline or comes back online.
* @deprecated Please use the events.nodeStateChanged observable and the extra events for structureCHanged and
* decomissioned instead.
*/
readonly stateInformationCallback?: (nodeId: NodeId, state: NodeStateInformation) => void;
};
export class NodeNotConnectedError extends MatterError {}
interface SubscriptionHandlerCallbacks {
attributeListener: (data: DecodedAttributeReportValue<any>, valueChanged?: boolean, oldValue?: unknown) => void;
eventListener: (data: DecodedEventReportValue<any>) => void;
updateTimeoutHandler: Timer.Callback;
subscriptionAlive: () => void;
}
/**
* Class to represents one node that is paired/commissioned with the matter.js Controller. Instances are returned by
* the CommissioningController on commissioning or when connecting.
*/
export class PairedNode {
readonly #endpoints = new Map<EndpointNumber, Endpoint>();
#interactionClient: InteractionClient;
#reconnectDelayTimer?: Timer;
#newChannelReconnectDelayTimer = Time.getTimer(
"New Channel Reconnect Delay",
NEW_SESSION_WHILE_DISCOVERY_RECONNECT_DELAY_MS,
() => {
if (
this.#connectionState === NodeStates.WaitingForDeviceDiscovery ||
this.#connectionState === NodeStates.Reconnecting
) {
logger.info(
`Node ${this.nodeId}: Still not connected after new session establishment, trying to reconnect ...`,
);
// Try last known address first to speed up reconnection
this.#setConnectionState(NodeStates.Reconnecting);
this.#scheduleReconnect(0);
}
},
);
#reconnectErrorCount = 0;
readonly #updateEndpointStructureTimer = Time.getTimer(
"Endpoint structure update",
STRUCTURE_UPDATE_TIMEOUT_MS,
() =>
this.#updateEndpointStructure().catch(error =>
logger.warn(`Node ${this.nodeId}: Error updating endpoint structure`, error),
),
);
#connectionState: NodeStates = NodeStates.Disconnected;
#reconnectionInProgress = false;
#localInitializationDone = false;
#remoteInitializationInProgress = false;
#remoteInitializationDone = false;
#nodeDetails: DeviceInformation;
#construction: Construction<PairedNode>;
#clientReconnectInProgress = false;
#currentSubscriptionHandler?: SubscriptionHandlerCallbacks;
readonly #commissioningController: CommissioningController;
#options: CommissioningControllerNodeOptions;
readonly #reconnectFunc: (discoveryType?: NodeDiscoveryType, noForcedConnection?: boolean) => Promise<void>;
#currentSubscriptionIntervalS?: number;
#crypto: Crypto;
readonly events = {
/**
* Emitted when the node is initialized from local data. These data usually are stale, but you can still already
* use the node to interact with the device. If no local data are available this event will be emitted together
* with the initializedFromRemote event.
*/
initialized: AsyncObservable<[details: DeviceInformationData]>(),
/**
* Emitted when the node is fully initialized from remote and all attributes and events are subscribed.
* This event can also be awaited if code needs to be blocked until the node is fully initialized.
*/
initializedFromRemote: AsyncObservable<[details: DeviceInformationData]>(),
/** Emitted when the state of the node changes. */
stateChanged: Observable<[nodeState: NodeStates]>(),
/**
* Emitted when an attribute value changes. If the oldValue is undefined then no former value was known.
*/
attributeChanged: Observable<[data: DecodedAttributeReportValue<any>, oldValue: any]>(),
/** Emitted when an event is triggered. */
eventTriggered: Observable<[DecodedEventReportValue<any>]>(),
/** Emitted when the structure of the node changes (Endpoints got added or also removed). */
structureChanged: Observable<[void]>(),
/** Emitted when the node is decommissioned. */
decommissioned: Observable<[void]>(),
/** Emitted when a subscription alive trigger is received (max interval trigger or any data update) */
connectionAlive: Observable<[void]>(),
};
static async create(
nodeId: NodeId,
commissioningController: CommissioningController,
options: CommissioningControllerNodeOptions = {},
knownNodeDetails: DeviceInformationData,
interactionClient: InteractionClient,
reconnectFunc: (discoveryType?: NodeDiscoveryType, noForcedConnection?: boolean) => Promise<void>,
assignDisconnectedHandler: (handler: () => Promise<void>) => void,
sessions: BasicSet<NodeSession>,
crypto: Crypto,
storedAttributeData?: DecodedAttributeReportValue<any>[],
): Promise<PairedNode> {
const node = new PairedNode(
nodeId,
commissioningController,
options,
knownNodeDetails,
interactionClient,
reconnectFunc,
assignDisconnectedHandler,
sessions,
crypto,
storedAttributeData,
);
await node.construction;
return node;
}
constructor(
readonly nodeId: NodeId,
commissioningController: CommissioningController,
options: CommissioningControllerNodeOptions = {},
knownNodeDetails: DeviceInformationData,
interactionClient: InteractionClient,
reconnectFunc: (discoveryType?: NodeDiscoveryType, noForcedConnection?: boolean) => Promise<void>,
assignDisconnectedHandler: (handler: () => Promise<void>) => void,
sessions: BasicSet<NodeSession, NodeSession>,
crypto: Crypto,
storedAttributeData?: DecodedAttributeReportValue<any>[],
) {
assignDisconnectedHandler(async () => {
logger.info(
`Node ${this.nodeId}: Session disconnected${
this.#connectionState !== NodeStates.Disconnected ? ", trying to reconnect ..." : ""
}`,
);
if (this.#connectionState === NodeStates.Connected) {
this.#scheduleReconnect();
}
});
this.#commissioningController = commissioningController;
this.#options = options;
this.#reconnectFunc = reconnectFunc;
this.#crypto = crypto;
this.#interactionClient = interactionClient;
if (this.#interactionClient.isReconnectable) {
this.#interactionClient.channelUpdated.on(() => {
// When we had planned a reconnect because of a disconnect we can stop the timer now
if (
this.#reconnectDelayTimer?.isRunning &&
!this.#clientReconnectInProgress &&
!this.#reconnectionInProgress &&
this.#connectionState === NodeStates.Reconnecting
) {
logger.info(`Node ${this.nodeId}: Got a reconnect, so reconnection not needed anymore ...`);
this.#reconnectDelayTimer?.stop();
this.#reconnectDelayTimer = undefined;
this.#setConnectionState(NodeStates.Connected);
}
});
} else {
logger.warn(
`Node ${this.nodeId}: InteractionClient is not reconnectable, no automatic reconnection will happen in case of errors.`,
);
}
this.#nodeDetails = new DeviceInformation(nodeId, knownNodeDetails);
logger.info(`Node ${this.nodeId}: Created paired node with device data`, this.#nodeDetails.meta);
sessions.added.on(session => {
if (
session.isInitiator || // If we initiated the session we do not need to react on it
session.peerNodeId !== this.nodeId || // no session for this node
this.state !== NodeStates.WaitingForDeviceDiscovery
) {
return;
}
this.#newChannelReconnectDelayTimer.stop().start();
});
this.#construction = Construction(this, async () => {
// We try to initialize from stored data already
if (storedAttributeData !== undefined) {
await this.#initializeFromStoredData(storedAttributeData);
}
if (this.#options.autoConnect !== false) {
// This kicks of the remote initialization and automatic reconnection handling if it can not be connected
this.#initialize().catch(error => {
logger.info(`Node ${nodeId}: Error during remote initialization`, error);
if (this.state !== NodeStates.Disconnected) {
this.#setConnectionState(NodeStates.WaitingForDeviceDiscovery);
this.#scheduleReconnect();
}
});
}
});
}
get construction() {
return this.#construction;
}
get isConnected() {
return this.#connectionState === NodeStates.Connected;
}
/** Returns the Node connection state. */
get state() {
return this.#connectionState;
}
/** Returns the BasicInformation cluster metadata collected from the device. */
get basicInformation() {
return this.#nodeDetails.basicInformation;
}
/** Returns the general capability metadata collected from the device. */
get deviceInformation() {
return this.#nodeDetails.meta;
}
/** Is the Node fully initialized with formerly stored subscription data? False when the node was never connected so far. */
get localInitializationDone() {
return this.#localInitializationDone;
}
/** Is the Node fully initialized with remote subscription or read data? */
get remoteInitializationDone() {
return this.#remoteInitializationDone;
}
/** Is the Node initialized - locally or remotely? */
get initialized() {
return this.#remoteInitializationDone || this.#localInitializationDone;
}
/** If a subscription is established then this is the interval in seconds, otherwise undefined */
get currentSubscriptionIntervalSeconds() {
return this.#currentSubscriptionIntervalS;
}
#invalidateSubscriptionHandler() {
if (this.#currentSubscriptionHandler !== undefined) {
// Make sure the former handlers do not trigger anymore
this.#currentSubscriptionHandler.attributeListener = () => {};
this.#currentSubscriptionHandler.eventListener = () => {};
this.#currentSubscriptionHandler.updateTimeoutHandler = () => {};
this.#currentSubscriptionHandler.subscriptionAlive = () => {};
}
}
#setConnectionState(state: NodeStates) {
if (
this.#connectionState === state ||
(this.#connectionState === NodeStates.WaitingForDeviceDiscovery && state === NodeStates.Reconnecting)
)
return;
this.#connectionState = state;
if (state !== NodeStates.Connected) {
this.#currentSubscriptionIntervalS = undefined;
}
this.#options.stateInformationCallback?.(this.nodeId, state as unknown as NodeStateInformation);
this.events.stateChanged.emit(state);
if (state === NodeStates.Disconnected) {
this.#reconnectDelayTimer?.stop();
this.#reconnectDelayTimer = undefined;
}
}
/** Make sure to not request a new Interaction client multiple times in parallel. */
async #handleReconnect(discoveryType?: NodeDiscoveryType): Promise<void> {
if (this.#clientReconnectInProgress) {
throw new NodeNotConnectedError("Reconnection already in progress. Node not reachable currently.");
}
this.#clientReconnectInProgress = true;
try {
await this.#reconnectFunc(discoveryType);
} finally {
this.#clientReconnectInProgress = false;
}
}
/**
* Schedule a connection to the device. This method is non-blocking and will return immediately.
* The connection happens in the background. Please monitor the state events of the node to see if the
* connection was successful.
* The provided connection options will be set and used internally if the node reconnects successfully.
*/
connect(connectOptions?: CommissioningControllerNodeOptions) {
if (connectOptions !== undefined) {
this.#options = connectOptions;
}
this.triggerReconnect();
}
/**
* Trigger a reconnection to the device. This method is non-blocking and will return immediately.
* The reconnection happens in the background. Please monitor the state events of the node to see if the
* reconnection was successful.
*/
triggerReconnect() {
if (this.#reconnectionInProgress || this.#remoteInitializationInProgress) {
logger.info(
`Node ${this.nodeId}: Ignoring reconnect request because ${this.#remoteInitializationInProgress ? "initialization" : "reconnect"} already in progress.`,
);
return;
}
this.#scheduleReconnect(0);
}
/**
* Force a reconnection to the device.
* This method is mainly used internally to reconnect after the active session
* was closed or the device went offline and was detected as being online again.
* Please note that this method does not return until the device is reconnected.
* Please use triggerReconnect method for a non-blocking reconnection triggering.
*/
async reconnect(connectOptions?: CommissioningControllerNodeOptions) {
if (connectOptions !== undefined) {
this.#options = connectOptions;
}
if (this.#reconnectionInProgress || this.#remoteInitializationInProgress) {
logger.debug(
`Node ${this.nodeId}: Ignoring reconnect request because ${this.#remoteInitializationInProgress ? "initialization" : "reconnect"} already underway.`,
);
return;
}
if (this.#reconnectDelayTimer?.isRunning) {
this.#reconnectDelayTimer.stop();
}
this.#reconnectionInProgress = true;
if (this.#connectionState !== NodeStates.WaitingForDeviceDiscovery) {
this.#setConnectionState(NodeStates.Reconnecting);
try {
// First try a reconnect to known addresses to see if the device is reachable
await this.#handleReconnect(NodeDiscoveryType.None);
this.#reconnectionInProgress = false;
await this.#initialize();
return;
} catch (error) {
if (error instanceof MatterError) {
logger.info(
`Node ${this.nodeId}: Simple re-establishing session did not worked. Reconnect ... `,
error,
);
} else {
this.#reconnectionInProgress = false;
throw error;
}
}
}
this.#setConnectionState(NodeStates.WaitingForDeviceDiscovery);
try {
await this.#initialize();
} catch (error) {
MatterError.accept(error);
if (error instanceof UnknownNodeError) {
logger.info(`Node ${this.nodeId}: Node is unknown by controller, we can not connect.`);
this.#setConnectionState(NodeStates.Disconnected);
} else if (this.#connectionState === NodeStates.Disconnected) {
logger.info(`Node ${this.nodeId}: No reconnection desired because requested status is Disconnected.`);
} else {
if (error instanceof ChannelStatusResponseError) {
logger.info(`Node ${this.nodeId}: Error while establishing new Channel, retrying ...`, error);
} else if (error instanceof StatusResponseError) {
logger.info(`Node ${this.nodeId}: Error while communicating with the device, retrying ...`, error);
} else {
logger.info(`Node ${this.nodeId}: Error waiting for device rediscovery, retrying`, error);
}
this.#reconnectErrorCount++;
this.#scheduleReconnect();
}
} finally {
this.#reconnectionInProgress = false;
}
}
/** Ensure that the node is connected by creating a new InteractionClient if needed. */
async #ensureConnection(forceConnect = false): Promise<InteractionClient> {
if (this.#connectionState === NodeStates.Disconnected) {
// Disconnected and having an InteractionClient means we initialized with an Offline one, so we do
// connection now on usage
this.#setConnectionState(NodeStates.Reconnecting);
return this.#interactionClient;
}
if (this.#connectionState === NodeStates.Connected && !forceConnect) {
return this.#interactionClient;
}
if (forceConnect) {
this.#setConnectionState(NodeStates.WaitingForDeviceDiscovery);
}
await this.#handleReconnect(NodeDiscoveryType.FullDiscovery);
if (!forceConnect) {
this.#setConnectionState(NodeStates.Connected);
}
return this.#interactionClient;
}
async #initializeFromStoredData(storedAttributeData: DecodedAttributeReportValue<any>[]) {
const { autoSubscribe } = this.#options;
if (this.#remoteInitializationDone || this.#localInitializationDone || autoSubscribe === false) return;
// Minimum sanity check that we have at least data for the Root endpoint and one other endpoint to initialize
let rootEndpointIncluded = false;
let otherEndpointIncluded = false;
if (
!storedAttributeData.some(({ path: { endpointId } }) => {
if (endpointId === 0) {
rootEndpointIncluded = true;
} else {
otherEndpointIncluded = true;
}
return rootEndpointIncluded && otherEndpointIncluded;
})
) {
return;
}
await this.#initializeEndpointStructure(storedAttributeData);
// Inform interested parties that the node is initialized
await this.events.initialized.emit(this.#nodeDetails.toStorageData());
this.#localInitializationDone = true;
}
/**
* Initialize the node after the InteractionClient was created and to subscribe attributes and events if requested.
*/
async #initialize() {
if (this.#remoteInitializationInProgress) {
logger.info(`Node ${this.nodeId}: Remote initialization already in progress ...`);
return;
}
this.#remoteInitializationInProgress = true;
try {
// Enforce a new Connection
await this.#ensureConnection(true); // This sets state to connected when successful!
const { autoSubscribe, attributeChangedCallback, eventTriggeredCallback } = this.#options;
let deviceDetailsUpdated = false;
// We need to query some Device metadata because we do not have them (or update them anyway)
if (!this.#nodeDetails.valid || (autoSubscribe === false && !this.#remoteInitializationDone)) {
await this.#nodeDetails.enhanceDeviceDetailsFromRemote(this.#interactionClient);
deviceDetailsUpdated = true;
}
const anyInitializationDone = this.#localInitializationDone || this.#remoteInitializationDone;
if (autoSubscribe !== false) {
const { attributeReports, maxInterval } = await this.subscribeAllAttributesAndEvents({
ignoreInitialTriggers: !anyInitializationDone, // Trigger on updates only after initialization
attributeChangedCallback: (data, oldValue) => {
attributeChangedCallback?.(this.nodeId, data);
this.events.attributeChanged.emit(data, oldValue);
},
eventTriggeredCallback: data => {
eventTriggeredCallback?.(this.nodeId, data);
this.events.eventTriggered.emit(data);
},
}); // Ignore Triggers from Subscribing during initialization
if (attributeReports === undefined) {
throw new InternalError("No attribute reports received when subscribing to all values!");
}
await this.#initializeEndpointStructure(attributeReports, anyInitializationDone);
this.#remoteInitializationInProgress = false; // We are done, rest is bonus and should not block reconnections
if (!deviceDetailsUpdated) {
const rootEndpoint = this.getRootEndpoint();
if (rootEndpoint !== undefined) {
await this.#nodeDetails.enhanceDeviceDetailsFromCache(rootEndpoint);
}
}
this.#currentSubscriptionIntervalS = maxInterval;
} else {
const allClusterAttributes = await this.readAllAttributes();
await this.#initializeEndpointStructure(allClusterAttributes, anyInitializationDone);
this.#remoteInitializationInProgress = false; // We are done, rest is bonus and should not block reconnections
}
if (!this.#remoteInitializationDone) {
try {
await this.#commissioningController.validateAndUpdateFabricLabel(this.nodeId);
} catch (error) {
logger.info(`Node ${this.nodeId}: Error updating fabric label`, error);
}
}
this.#reconnectErrorCount = 0;
this.#remoteInitializationDone = true;
await this.events.initializedFromRemote.emit(this.#nodeDetails.toStorageData());
if (!this.#localInitializationDone) {
this.#localInitializationDone = true;
await this.events.initialized.emit(this.#nodeDetails.toStorageData());
}
this.#setConnectionState(NodeStates.Connected);
} finally {
this.#remoteInitializationInProgress = false;
}
}
/**
* Request the current InteractionClient for custom special interactions with the device. Usually the
* ClusterClients of the Devices of the node should be used instead. An own InteractionClient is only needed
* when you want to read or write multiple attributes or events in a single request or send batch invokes.
*/
getInteractionClient() {
return this.#ensureConnection();
}
/** Method to log the structure of this node with all endpoint and clusters. */
logStructure() {
const rootEndpoint = this.#endpoints.get(EndpointNumber(0));
if (rootEndpoint === undefined) {
logger.info(`Node ${this.nodeId} has not yet been initialized!`);
return;
}
logger.info(this);
}
/**
* Subscribe to all attributes and events of the device. Unless setting the Controller property autoSubscribe to
* false this is executed automatically. Alternatively you can manually subscribe by calling this method.
*/
async subscribeAllAttributesAndEvents(options?: {
ignoreInitialTriggers?: boolean;
attributeChangedCallback?: (data: DecodedAttributeReportValue<any>, oldValue: any) => void;
eventTriggeredCallback?: (data: DecodedEventReportValue<any>) => void;
}) {
options = options ?? {};
const { attributeChangedCallback, eventTriggeredCallback } = options;
let { ignoreInitialTriggers = false } = options;
const { minIntervalFloorSeconds, maxIntervalCeilingSeconds } =
this.#nodeDetails.determineSubscriptionParameters(this.#options);
const { threadConnected } = this.#nodeDetails.meta ?? {};
this.#invalidateSubscriptionHandler();
const subscriptionHandler: SubscriptionHandlerCallbacks = {
attributeListener: (data, changed, oldValue) => {
if (ignoreInitialTriggers || changed === false) {
return;
}
const {
path: { endpointId, clusterId, attributeId },
value,
} = data;
const device = this.#endpoints.get(endpointId);
if (device === undefined) {
logger.info(
`Node ${this.nodeId} Ignoring received attribute update for unknown endpoint ${endpointId}!`,
);
return;
}
const cluster = device.getClusterClientById(clusterId);
if (cluster === undefined) {
logger.info(
`Node ${this.nodeId} Ignoring received attribute update for unknown cluster ${Diagnostic.hex(
clusterId,
)} on endpoint ${endpointId}!`,
);
return;
}
logger.debug(
`Node ${this.nodeId} Trigger attribute update for ${endpointId}.${cluster.name}.${attributeId} to ${Diagnostic.json(
value,
)} (changed: ${changed})`,
);
asClusterClientInternal(cluster)._triggerAttributeUpdate(attributeId, value);
attributeChangedCallback?.(data, oldValue);
this.#checkAttributesForNeededStructureUpdate(endpointId, clusterId, attributeId);
},
eventListener: data => {
if (ignoreInitialTriggers) return;
const {
path: { endpointId, clusterId, eventId },
events,
} = data;
const device = this.#endpoints.get(endpointId);
if (device === undefined) {
logger.info(`Node ${this.nodeId} Ignoring received event for unknown endpoint ${endpointId}!`);
return;
}
const cluster = device.getClusterClientById(clusterId);
if (cluster === undefined) {
logger.info(
`Node ${this.nodeId} Ignoring received event for unknown cluster ${Diagnostic.hex(
clusterId,
)} on endpoint ${endpointId}!`,
);
return;
}
logger.debug(
`Node ${this.nodeId} Trigger event update for ${endpointId}.${cluster.name}.${eventId} for ${events.length} events`,
);
asClusterClientInternal(cluster)._triggerEventUpdate(eventId, events);
eventTriggeredCallback?.(data);
this.#checkEventsForNeededStructureUpdate(endpointId, clusterId, eventId);
},
updateTimeoutHandler: async () => {
logger.info(`Node ${this.nodeId}: Subscription timed out ... trying to re-establish ...`);
this.#setConnectionState(NodeStates.Reconnecting);
this.#reconnectionInProgress = true;
try {
const { maxInterval } = await this.subscribeAllAttributesAndEvents({
...options,
ignoreInitialTriggers: false,
});
this.#setConnectionState(NodeStates.Connected);
this.#currentSubscriptionIntervalS = maxInterval;
} catch (error) {
logger.info(
`Node ${this.nodeId}: Error resubscribing to all attributes and events. Try to reconnect ...`,
error,
);
this.#scheduleReconnect();
} finally {
this.#reconnectionInProgress = false;
}
},
subscriptionAlive: () => {
if (this.#reconnectDelayTimer?.isRunning && this.#connectionState === NodeStates.Reconnecting) {
logger.info(`Node ${this.nodeId}: Got subscription update, so reconnection not needed anymore ...`);
this.#reconnectDelayTimer.stop();
this.#reconnectDelayTimer = undefined;
this.#setConnectionState(NodeStates.Connected);
}
this.events.connectionAlive.emit();
},
};
this.#currentSubscriptionHandler = subscriptionHandler;
const maxKnownEventNumber = this.#interactionClient.maxKnownEventNumber;
// We first update all values by doing a read all on the device
// We do not enrich existing data because we just want to store updated data
const attributeData = await this.#interactionClient.getAllAttributes({
dataVersionFilters: this.#interactionClient.getCachedClusterDataVersions(),
executeQueued: !!threadConnected, // We queue subscriptions for thread devices
});
await this.#interactionClient.addAttributesToCache(attributeData);
attributeData.length = 0; // Clear the array to save memory
// If we subscribe anything we use these data to create the endpoint structure, so we do not need to fetch again
const initialSubscriptionData = await this.#interactionClient.subscribeAllAttributesAndEvents({
isUrgent: true,
minIntervalFloorSeconds,
maxIntervalCeilingSeconds,
keepSubscriptions: false,
dataVersionFilters: this.#interactionClient.getCachedClusterDataVersions(),
enrichCachedAttributeData: true,
eventFilters: maxKnownEventNumber !== undefined ? [{ eventMin: maxKnownEventNumber + 1n }] : undefined,
executeQueued: !!threadConnected, // We queue subscriptions for thread devices
attributeListener: (data, changed, oldValue) =>
subscriptionHandler.attributeListener(data, changed, oldValue),
eventListener: data => subscriptionHandler.eventListener(data),
updateTimeoutHandler: () => subscriptionHandler.updateTimeoutHandler(),
updateReceived: () => subscriptionHandler.subscriptionAlive(),
});
// After initial data are processed we want to send out callbacks, so we set ignoreInitialTriggers to false
ignoreInitialTriggers = false;
return initialSubscriptionData;
}
/** Read all attributes of the devices and return them. If a stored state exists this is used to minimize needed traffic. */
async readAllAttributes() {
return this.#interactionClient.getAllAttributes({
dataVersionFilters: this.#interactionClient.getCachedClusterDataVersions(),
enrichCachedAttributeData: true,
});
}
#checkAttributesForNeededStructureUpdate(
_endpointId: EndpointNumber,
clusterId: ClusterId,
attributeId: AttributeId,
) {
// Any change in the Descriptor Cluster partsList attribute requires a reinitialization of the endpoint structure
let structureUpdateNeeded = false;
if (clusterId === DescriptorCluster.id) {
switch (attributeId) {
case DescriptorCluster.attributes.partsList.id:
case DescriptorCluster.attributes.serverList.id:
case DescriptorCluster.attributes.deviceTypeList.id:
structureUpdateNeeded = true;
break;
}
}
if (!structureUpdateNeeded) {
switch (attributeId) {
case FeatureMap.id:
case AttributeList.id:
case AcceptedCommandList.id:
case ClusterRevision.id:
structureUpdateNeeded = true;
break;
}
}
if (structureUpdateNeeded) {
logger.info(`Node ${this.nodeId}: Endpoint structure needs to be updated ...`);
this.#updateEndpointStructureTimer.stop().start();
}
}
#checkEventsForNeededStructureUpdate(_endpointId: EndpointNumber, clusterId: ClusterId, eventId: EventId) {
// When we subscribe all data here then we can also catch this case and handle it
if (clusterId === BasicInformation.Cluster.id && eventId === BasicInformation.Cluster.events.shutDown.id) {
this.#handleNodeShutdown();
}
}
/** Handles a node shutDown event (if supported by the node and received). */
#handleNodeShutdown() {
logger.info(`Node ${this.nodeId}: Node shutdown detected, trying to reconnect ...`);
this.#scheduleReconnect(RECONNECT_DELAY_AFTER_SHUTDOWN_MS);
}
#scheduleReconnect(delay?: number) {
if (this.state !== NodeStates.WaitingForDeviceDiscovery) {
this.#setConnectionState(NodeStates.Reconnecting);
}
if (!this.#reconnectDelayTimer?.isRunning) {
this.#reconnectDelayTimer?.stop();
}
if (delay === undefined) {
// Calculate a delay with a backoff strategy based on errorCount and maximum 10 minutes
delay = Math.min(RECONNECT_DELAY_MS * 2 ** this.#reconnectErrorCount, RECONNECT_MAX_DELAY_MS);
}
logger.info(`Node ${this.nodeId}: Reconnecting in ${Math.round(delay / 1000)}s ...`);
this.#reconnectDelayTimer = Time.getTimer("Reconnect delay", delay, async () => await this.reconnect());
this.#reconnectDelayTimer.start();
}
async #updateEndpointStructure() {
const allClusterAttributes = await this.readAllAttributes();
await this.#initializeEndpointStructure(allClusterAttributes, true);
this.#options.stateInformationCallback?.(this.nodeId, NodeStateInformation.StructureChanged);
this.events.structureChanged.emit();
}
/** Reads all data from the device and create a device object structure out of it. */
async #initializeEndpointStructure(
allClusterAttributes: DecodedAttributeReportValue<any>[],
updateStructure = false,
) {
const allData = structureReadAttributeDataToClusterObject(allClusterAttributes);
if (updateStructure) {
// Find out what we need to remove or retain
const endpointsToRemove = new Set<EndpointNumber>(this.#endpoints.keys());
for (const [endpointId] of Object.entries(allData)) {
const endpointIdNumber = EndpointNumber(parseInt(endpointId));
if (this.#endpoints.has(endpointIdNumber)) {
logger.debug(`Node ${this.nodeId}: Retaining device`, endpointId);
endpointsToRemove.delete(endpointIdNumber);
}
}
// And remove all endpoints no longer in the structure
for (const endpointId of endpointsToRemove.values()) {
logger.debug(`Node ${this.nodeId}: Removing device`, endpointId);
this.#endpoints.get(endpointId)?.removeFromStructure();
this.#endpoints.delete(endpointId);
}
} else {
this.#endpoints.clear();
}
const partLists = new Map<EndpointNumber, EndpointNumber[]>();
for (const [endpointId, clusters] of Object.entries(allData)) {
const endpointIdNumber = EndpointNumber(parseInt(endpointId));
const descriptorData = clusters[DescriptorCluster.id] as AttributeClientValues<
typeof DescriptorCluster.attributes
>;
partLists.set(endpointIdNumber, descriptorData.partsList);
if (this.#endpoints.has(endpointIdNumber)) {
// Endpoint exists already, so mo need to create device instance again
continue;
}
logger.debug(`Node ${this.nodeId}: Creating device`, endpointId, Diagnostic.json(clusters));
this.#endpoints.set(
endpointIdNumber,
this.#createDevice(endpointIdNumber, clusters, this.#interactionClient),
);
}
this.#structureEndpoints(partLists);
}
/** Bring the endpoints in a structure based on their partsList attribute. */
#structureEndpoints(partLists: Map<EndpointNumber, EndpointNumber[]>) {
logger.debug(
`Node ${this.nodeId}: Endpoints from PartsLists`,
Diagnostic.json(Array.from(partLists.entries())),
);
const endpointUsages: { [key: EndpointNumber]: EndpointNumber[] } = {};
Array.from(partLists.entries()).forEach(([parent, partsList]) =>
partsList.forEach(endPoint => {
if (endPoint === parent) {
// There could be more cases of invalid and cycling structures that never should happen ... so lets not over optimize to try to find all of them right now
logger.warn(`Node ${this.nodeId}: Endpoint ${endPoint} is referencing itself!`);
return;
}
endpointUsages[endPoint] = endpointUsages[endPoint] || [];
endpointUsages[endPoint].push(parent);
}),
);
logger.debug(`Node ${this.nodeId}: Endpoint usages`, Diagnostic.json(endpointUsages));
while (true) {
// get all endpoints with only one usage
const singleUsageEndpoints = Object.entries(endpointUsages).filter(([_, usages]) => usages.length === 1);
if (singleUsageEndpoints.length === 0) {
if (Object.entries(endpointUsages).length)
throw new InternalError(`Endpoint structure for Node ${this.nodeId} could not be parsed!`);
break;
}
logger.debug(`Node ${this.nodeId}: Processing Endpoint ${Diagnostic.json(singleUsageEndpoints)}`);
const idsToCleanup: { [key: EndpointNumber]: boolean } = {};
singleUsageEndpoints.forEach(([childId, usages]) => {
const childEndpointId = EndpointNumber(parseInt(childId));
const childEndpoint = this.#endpoints.get(childEndpointId);
const parentEndpoint = this.#endpoints.get(usages[0]);
if (childEndpoint === undefined || parentEndpoint === undefined) {
logger.warn(
`Node ${this.nodeId}: Endpoint ${usages[0]} not found in the data received from the device!`,
);
} else if (parentEndpoint.getChildEndpoint(childEndpointId) === undefined) {
logger.debug(
`Node ${this.nodeId}: Endpoint structure: Child: ${childEndpointId} -> Parent: ${parentEndpoint.number}`,
);
parentEndpoint.addChildEndpoint(childEndpoint);
}
delete endpointUsages[EndpointNumber(parseInt(childId))];
idsToCleanup[usages[0]] = true;
});
logger.debug(`Node ${this.nodeId}: Endpoint data Cleanup`, Diagnostic.json(idsToCleanup));
Object.keys(idsToCleanup).forEach(idToCleanup => {
Object.keys(endpointUsages).forEach(id => {
const usageId = EndpointNumber(parseInt(id));
endpointUsages[usageId] = endpointUsages[usageId].filter(
endpointId => endpointId !== parseInt(idToCleanup),
);
if (!endpointUsages[usageId].length) {
delete endpointUsages[usageId];
}
});
});
}
}
/** Create a device object from the data read from the device. */
#createDevice(
endpointId: EndpointNumber,
data: { [key: ClusterId]: { [key: string]: any } },
interactionClient: InteractionClient,
) {
const descriptorData = data[DescriptorCluster.id] as AttributeClientValues<typeof DescriptorCluster.attributes>;
const deviceTypes = descriptorData.deviceTypeList.flatMap(({ deviceType, revision }) => {
const deviceTypeDefinition = getDeviceTypeDefinitionFromModelByCode(deviceType);
if (deviceTypeDefinition === undefined) {
logger.info(
`NodeId ${this.nodeId}: Device type with code ${deviceType} not known, use generic replacement.`,
);
return UnknownDeviceType(deviceType, revision);
}
if (deviceTypeDefinition.revision < revision) {
logger.debug(
`NodeId ${this.nodeId}: Device type with code ${deviceType} and revision ${revision} not supported, some data might be unknown.`,
);
}
return deviceTypeDefinition;
});
if (deviceTypes.length === 0) {
logger.info(`NodeId ${this.nodeId}: No device type found for endpoint ${endpointId}, ignore`);
throw new MatterError(`NodeId ${this.nodeId}: No device type found for endpoint`);
}
const endpointClusters = Array<ClusterServerObj | ClusterClientObj>();
// Add ClusterClients for all server clusters of the device
for (const clusterId of descriptorData.serverList) {
const cluster = getClusterById(clusterId);
const clusterClient = ClusterClient(cluster, endpointId, interactionClient, data[clusterId]);
endpointClusters.push(clusterClient);
}
// TODO use the attributes attributeList, acceptedCommands, generatedCommands to create the ClusterClient/Server objects
// Add ClusterServers for all client clusters of the dev