@project-chip/matter.js
Version:
Matter protocol in pure js
692 lines (611 loc) • 25.6 kB
text/typescript
/**
* @license
* Copyright 2022-2025 Matter.js Authors
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Important note: This file is part of the legacy matter-node (internal) API and should not be used anymore directly!
* Please use the new API classes!
* @deprecated
*/
import { GeneralCommissioning } from "#clusters";
import { NodeCommissioningOptions } from "#CommissioningController.js";
import { ControllerStoreInterface } from "#ControllerStore.js";
import { CachedClientNodeStore } from "#device/CachedClientNodeStore.js";
import { DeviceInformationData } from "#device/DeviceInformation.js";
import {
Bytes,
ChannelType,
Construction,
Crypto,
CRYPTO_SYMMETRIC_KEY_LENGTH,
Environment,
ImplementationError,
Logger,
MatterError,
NetInterfaceSet,
ServerAddressIp,
serverAddressToString,
StorageBackendMemory,
StorageManager,
} from "#general";
import { LegacyControllerStore } from "#LegacyControllerStore.js";
import {
CertificateAuthority,
ChannelManager,
ClusterClient,
CommissioningError,
ControllerCommissioner,
DecodedAttributeReportValue,
DEFAULT_ADMIN_VENDOR_ID,
DEFAULT_FABRIC_ID,
DeviceAdvertiser,
DiscoveryAndCommissioningOptions,
DiscoveryData,
DiscoveryOptions,
ExchangeManager,
Fabric,
FabricBuilder,
FabricManager,
InstanceBroadcaster,
InteractionClientProvider,
NodeDiscoveryType,
OperationalPeer,
PeerAddress,
PeerAddressStore,
PeerSet,
ResumptionRecord,
RetransmissionLimitReachedError,
ScannerSet,
SecureChannelProtocol,
SessionManager,
SubscriptionClient,
} from "#protocol";
import {
CaseAuthenticatedTag,
ClusterId,
DiscoveryCapabilitiesBitmap,
EndpointNumber,
FabricId,
FabricIndex,
NodeId,
TypeFromPartialBitSchema,
VendorId,
} from "#types";
import { ClassExtends } from "@matter/general";
import { ControllerCommissioningFlow, MessageChannel } from "@matter/protocol";
export type CommissionedNodeDetails = {
operationalServerAddress?: ServerAddressIp;
discoveryData?: DiscoveryData;
deviceData?: DeviceInformationData;
};
const DEFAULT_FABRIC_INDEX = FabricIndex(1);
const CONTROLLER_CONNECTIONS_PER_FABRIC_AND_NODE = 3;
const CONTROLLER_MAX_PATHS_PER_INVOKE = 10;
const logger = Logger.get("MatterController");
// Operational peer extended with basic information as required for conversion to CommissionedNodeDetails
type CommissionedPeer = OperationalPeer & { deviceData?: DeviceInformationData };
// Backward-compatible persistence record for nodes
type StoredOperationalPeer = [NodeId, CommissionedNodeDetails];
export class MatterController {
public static async create(options: {
controllerStore: ControllerStoreInterface;
scanners: ScannerSet;
netInterfaces: NetInterfaceSet;
sessionClosedCallback?: (peerNodeId: NodeId) => void;
adminVendorId?: VendorId;
adminFabricId?: FabricId;
adminFabricIndex?: FabricIndex;
caseAuthenticatedTags?: CaseAuthenticatedTag[];
adminFabricLabel: string;
rootNodeId?: NodeId;
rootCertificateAuthority?: CertificateAuthority;
rootFabric?: Fabric;
crypto?: Crypto;
}): Promise<MatterController> {
const {
controllerStore,
scanners,
netInterfaces,
sessionClosedCallback,
adminVendorId,
adminFabricId = FabricId(DEFAULT_FABRIC_ID),
adminFabricIndex = FabricIndex(DEFAULT_FABRIC_INDEX),
caseAuthenticatedTags,
adminFabricLabel,
rootNodeId,
rootCertificateAuthority,
rootFabric,
} = options;
const crypto = options.crypto ?? Environment.default.get(Crypto);
const ca = rootCertificateAuthority ?? (await CertificateAuthority.create(crypto, controllerStore.caStorage));
const fabricStorage = controllerStore.fabricStorage;
let controller: MatterController | undefined = undefined;
// Check if we have a fabric stored in the storage, if yes initialize this one, else build a new one
if (rootFabric !== undefined || (await fabricStorage.has("fabric"))) {
const fabric = rootFabric ?? new Fabric(crypto, await fabricStorage.get<Fabric.Config>("fabric"));
if (Bytes.areEqual(fabric.rootCert, ca.rootCert)) {
logger.info("Used existing fabric");
controller = new MatterController({
controllerStore,
scanners,
netInterfaces,
certificateManager: ca,
fabric,
adminFabricLabel,
sessionClosedCallback,
});
fabric.storage = fabricStorage;
} else {
if (rootFabric !== undefined) {
throw new MatterError("Fabric CA certificate is not in sync with CA.");
}
logger.info("Fabric CA certificate changed ...");
if (await controllerStore.nodesStorage.has("commissionedNodes")) {
throw new MatterError(
"Fabric certificate changed, but commissioned nodes are still present. Please clear the storage.",
);
}
}
}
if (controller === undefined) {
logger.info("Creating new fabric");
const controllerNodeId = rootNodeId ?? NodeId.randomOperationalNodeId(crypto);
const ipkValue = crypto.randomBytes(CRYPTO_SYMMETRIC_KEY_LENGTH);
const fabricBuilder = await FabricBuilder.create(crypto);
await fabricBuilder.setRootCert(ca.rootCert);
fabricBuilder
.setRootNodeId(controllerNodeId)
.setIdentityProtectionKey(ipkValue)
.setRootVendorId(adminVendorId ?? DEFAULT_ADMIN_VENDOR_ID)
.setLabel(adminFabricLabel);
await fabricBuilder.setOperationalCert(
await ca.generateNoc(fabricBuilder.publicKey, adminFabricId, controllerNodeId, caseAuthenticatedTags),
);
const fabric = await fabricBuilder.build(adminFabricIndex);
fabric.storage = fabricStorage;
controller = new MatterController({
controllerStore,
scanners,
netInterfaces,
certificateManager: ca,
fabric,
adminFabricLabel,
sessionClosedCallback,
});
}
await controller.construction;
return controller;
}
public static async createAsPaseCommissioner(options: {
certificateAuthorityConfig?: CertificateAuthority.Configuration;
rootCertificateAuthority?: CertificateAuthority;
fabricConfig: Fabric.Config;
scanners: ScannerSet;
netInterfaces: NetInterfaceSet;
adminFabricLabel: string;
sessionClosedCallback?: (peerNodeId: NodeId) => void;
crypto?: Crypto;
}): Promise<MatterController> {
const {
certificateAuthorityConfig,
rootCertificateAuthority,
fabricConfig,
adminFabricLabel,
scanners,
netInterfaces,
sessionClosedCallback,
} = options;
const crypto = options.crypto ?? Environment.default.get(Crypto);
// Verify an appropriate network interface is available
if (!netInterfaces.hasInterfaceFor(ChannelType.BLE)) {
if (!scanners.hasScannerFor(ChannelType.UDP) || !netInterfaces.hasInterfaceFor(ChannelType.UDP, "::")) {
throw new ImplementationError(
"Ble must be initialized to create a Sub Commissioner without an IP network!",
);
}
logger.info("BLE is not enabled. Using only IP network for commissioning.");
}
if (rootCertificateAuthority === undefined && certificateAuthorityConfig === undefined) {
throw new ImplementationError("Either rootCertificateAuthority or certificateAuthorityConfig must be set.");
}
const certificateManager =
rootCertificateAuthority ?? (await CertificateAuthority.create(crypto, certificateAuthorityConfig));
// Stored data are temporary anyway and no node will be connected, so just use an in-memory storage
const storageManager = new StorageManager(new StorageBackendMemory());
await storageManager.initialize();
const fabric = new Fabric(crypto, fabricConfig);
// Check if we have a fabric stored in the storage, if yes initialize this one, else build a new one
const controller = new MatterController({
controllerStore: new LegacyControllerStore(storageManager.createContext("Commissioner")),
scanners,
netInterfaces,
certificateManager,
fabric,
adminFabricLabel,
sessionClosedCallback,
});
await controller.construction;
return controller;
}
readonly sessionManager: SessionManager;
private readonly netInterfaces = new NetInterfaceSet();
private readonly channelManager = new ChannelManager(CONTROLLER_CONNECTIONS_PER_FABRIC_AND_NODE);
private readonly exchangeManager: ExchangeManager;
private readonly peers: PeerSet;
private readonly clients: InteractionClientProvider;
private readonly commissioner: ControllerCommissioner;
#construction: Construction<MatterController>;
#store: ControllerStoreInterface;
readonly nodesStore: CommissionedNodeStore;
private readonly scanners: ScannerSet;
private readonly ca: CertificateAuthority;
private readonly fabric: Fabric;
private readonly sessionClosedCallback?: (peerNodeId: NodeId) => void;
#advertiser: DeviceAdvertiser;
get construction() {
return this.#construction;
}
constructor(options: {
controllerStore: ControllerStoreInterface;
scanners: ScannerSet;
netInterfaces: NetInterfaceSet;
certificateManager: CertificateAuthority;
fabric: Fabric;
adminFabricLabel: string;
sessionClosedCallback?: (peerNodeId: NodeId) => void;
}) {
const {
controllerStore,
scanners,
netInterfaces,
certificateManager,
fabric,
sessionClosedCallback,
adminFabricLabel,
} = options;
this.#store = controllerStore;
this.scanners = scanners;
this.netInterfaces = netInterfaces;
this.ca = certificateManager;
this.fabric = fabric;
this.sessionClosedCallback = sessionClosedCallback;
const fabricManager = new FabricManager(fabric.crypto);
fabricManager.addFabric(fabric);
// Overwrite the persist callback and store fabric when needed
fabric.persistCallback = async () => {
await this.#store.fabricStorage.set("fabric", this.fabric.config);
};
this.sessionManager = new SessionManager({
fabrics: fabricManager,
storage: controllerStore.sessionStorage,
parameters: {
maxPathsPerInvoke: CONTROLLER_MAX_PATHS_PER_INVOKE,
},
});
this.sessionManager.sessions.deleted.on(async session => {
this.sessionClosedCallback?.(session.peerNodeId);
});
const subscriptionClient = new SubscriptionClient();
this.exchangeManager = new ExchangeManager({
crypto: fabric.crypto,
sessionManager: this.sessionManager,
channelManager: this.channelManager,
transportInterfaces: this.netInterfaces,
});
this.exchangeManager.addProtocolHandler(new SecureChannelProtocol(this.sessionManager, fabricManager));
this.exchangeManager.addProtocolHandler(subscriptionClient);
// Adapts the historical storage format for MatterController to OperationalPeer objects
this.nodesStore = new CommissionedNodeStore(controllerStore, fabric);
this.nodesStore.peers = this.peers = new PeerSet({
sessions: this.sessionManager,
channels: this.channelManager,
exchanges: this.exchangeManager,
subscriptionClient,
scanners: this.scanners,
netInterfaces: this.netInterfaces,
store: this.nodesStore,
});
this.clients = new InteractionClientProvider(this.peers);
this.commissioner = new ControllerCommissioner({
peers: this.peers,
clients: this.clients,
scanners: this.scanners,
netInterfaces: this.netInterfaces,
exchanges: this.exchangeManager,
sessions: this.sessionManager,
ca: this.ca,
});
this.#advertiser = new DeviceAdvertiser({
fabrics: fabricManager,
sessions: this.sessionManager,
});
this.#construction = Construction(this, async () => {
await this.peers.construction.ready;
await this.sessionManager.construction.ready;
if (this.fabric.label !== adminFabricLabel) {
await fabric.setLabel(adminFabricLabel);
}
});
}
get nodeId() {
return this.fabric.rootNodeId;
}
get caConfig() {
return this.ca.config;
}
get fabricConfig() {
return this.fabric.config;
}
get sessions() {
return this.sessionManager.sessions;
}
getFabrics() {
return [this.fabric];
}
hasBroadcaster(broadcaster: InstanceBroadcaster) {
return this.#advertiser.hasBroadcaster(broadcaster);
}
addBroadcaster(broadcaster: InstanceBroadcaster) {
this.#advertiser.addBroadcaster(broadcaster);
}
async deleteBroadcaster(broadcaster: InstanceBroadcaster) {
await this.#advertiser.deleteBroadcaster(broadcaster);
}
public collectScanners(
discoveryCapabilities: TypeFromPartialBitSchema<typeof DiscoveryCapabilitiesBitmap> = { onIpNetwork: true },
) {
// Note we always scan via MDNS if available
return this.scanners.filter(
scanner =>
scanner.type === ChannelType.UDP || (discoveryCapabilities.ble && scanner.type === ChannelType.BLE),
);
}
/**
* Commission a device by its identifier and the Passcode. If a known address is provided this is tried first
* before discovering devices in the network. If multiple addresses or devices are found, they are tried all after
* each other. It returns the NodeId of the commissioned device.
* If it throws an PairRetransmissionLimitReachedError that means that no found device responded to the pairing
* request or the passode did not match to any discovered device/address.
*
* Use the connectNodeAfterCommissioning callback to implement an own logic to do the operative device discovery and
* to complete the commissioning process.
* Return true when the commissioning process is completed successfully, false on error.
*/
async commission(
options: NodeCommissioningOptions,
customizations?: {
completeCommissioningCallback?: (peerNodeId: NodeId, discoveryData?: DiscoveryData) => Promise<boolean>;
commissioningFlowImpl?: ClassExtends<ControllerCommissioningFlow>;
},
): Promise<NodeId> {
const commissioningOptions: DiscoveryAndCommissioningOptions = {
...options.commissioning,
fabric: this.fabric,
discovery: options.discovery,
passcode: options.passcode,
};
const { completeCommissioningCallback, commissioningFlowImpl } = customizations ?? {};
if (completeCommissioningCallback) {
commissioningOptions.finalizeCommissioning = async (peerAddress, discoveryData) => {
const result = await completeCommissioningCallback(peerAddress.nodeId, discoveryData);
if (!result) {
throw new RetransmissionLimitReachedError("Device could not be discovered");
}
};
}
commissioningOptions.commissioningFlowImpl = commissioningFlowImpl;
const address = await this.commissioner.commissionWithDiscovery(commissioningOptions);
await this.fabric.persist();
return address.nodeId;
}
async disconnect(nodeId: NodeId) {
return this.peers.disconnect(this.fabric.addressOf(nodeId));
}
async connectPaseChannel(options: NodeCommissioningOptions) {
const { paseSecureChannel } = await this.commissioner.discoverAndEstablishPase({
...options.commissioning,
fabric: this.fabric,
discovery: options.discovery,
passcode: options.passcode,
});
logger.warn("PASE channel established", paseSecureChannel.session.name, paseSecureChannel.session.isSecure);
return paseSecureChannel;
}
async removeNode(nodeId: NodeId) {
return this.peers.delete(this.fabric.addressOf(nodeId));
}
/**
* Method to complete the commissioning process to a node which was initialized with a PASE secure channel.
*/
async completeCommissioning(peerNodeId: NodeId, discoveryData?: DiscoveryData) {
// Look for the device broadcast over MDNS and do CASE pairing
const interactionClient = await this.connect(
peerNodeId,
{
discoveryType: NodeDiscoveryType.TimedDiscovery,
timeoutSeconds: 120,
discoveryData,
},
true,
); // Wait maximum 120s to find the operational device for commissioning process
const generalCommissioningClusterClient = ClusterClient(
GeneralCommissioning.Cluster,
EndpointNumber(0),
interactionClient,
);
const { errorCode, debugText } = await generalCommissioningClusterClient.commissioningComplete(undefined, {
useExtendedFailSafeMessageResponseTimeout: true,
});
if (errorCode !== GeneralCommissioning.CommissioningError.Ok) {
// We might have added data for an operational address that we need to cleanup
await this.peers.delete(this.fabric.addressOf(peerNodeId));
throw new CommissioningError(`Commission error on commissioningComplete: ${errorCode}, ${debugText}`);
}
await this.fabric.persist();
}
isCommissioned() {
return this.peers.size > 0;
}
getCommissionedNodes() {
return this.peers.map(peer => peer.address.nodeId);
}
getCommissionedNodesDetails() {
return this.peers.map(peer => {
const { address, operationalAddress, discoveryData, deviceData } = peer as CommissionedPeer;
return {
nodeId: address.nodeId,
operationalAddress: operationalAddress ? serverAddressToString(operationalAddress) : undefined,
advertisedName: discoveryData?.DN,
discoveryData,
deviceData,
};
});
}
getCommissionedNodeDetails(nodeId: NodeId) {
const nodeDetails = this.peers.get(this.fabric.addressOf(nodeId)) as CommissionedPeer;
if (nodeDetails === undefined) {
throw new Error(`Node ${nodeId} is not commissioned.`);
}
const { address, operationalAddress, discoveryData, deviceData } = nodeDetails;
return {
nodeId: address.nodeId,
operationalAddress: operationalAddress ? serverAddressToString(operationalAddress) : undefined,
advertisedName: discoveryData?.DN,
discoveryData,
deviceData,
};
}
async enhanceCommissionedNodeDetails(nodeId: NodeId, deviceData: DeviceInformationData) {
const nodeDetails = this.peers.get(this.fabric.addressOf(nodeId)) as CommissionedPeer;
if (nodeDetails === undefined) {
throw new Error(`Node ${nodeId} is not commissioned.`);
}
nodeDetails.deviceData = deviceData;
await this.nodesStore.save();
}
/**
* Connect to the device by opening a channel and creating a new CASE session if necessary.
* Returns a InteractionClient on success.
*/
async connect(peerNodeId: NodeId, discoveryOptions: DiscoveryOptions, allowUnknownPeer?: boolean) {
return this.clients.connect(this.fabric.addressOf(peerNodeId), { discoveryOptions, allowUnknownPeer });
}
createInteractionClient(peerNodeIdOrChannel: NodeId | MessageChannel, discoveryOptions: DiscoveryOptions) {
if (peerNodeIdOrChannel instanceof MessageChannel) {
return this.clients.getInteractionClientForChannel(peerNodeIdOrChannel);
}
return this.clients.getInteractionClient(this.fabric.addressOf(peerNodeIdOrChannel), discoveryOptions);
}
async getNextAvailableSessionId() {
return this.sessionManager.getNextAvailableSessionId();
}
getResumptionRecord(resumptionId: Uint8Array) {
return this.sessionManager.findResumptionRecordById(resumptionId);
}
findResumptionRecordByNodeId(nodeId: NodeId) {
return this.sessionManager.findResumptionRecordByAddress(this.fabric.addressOf(nodeId));
}
async saveResumptionRecord(resumptionRecord: ResumptionRecord) {
return this.sessionManager.saveResumptionRecord(resumptionRecord);
}
announce() {
// Announce the controller itself
return this.#advertiser.advertise();
}
async close() {
await this.peers.close();
await this.exchangeManager.close();
await this.sessionManager.close();
await this.channelManager.close();
await this.netInterfaces.close();
await this.#advertiser.close();
}
getActiveSessionInformation() {
return this.sessionManager.getActiveSessionInformation();
}
async getStoredClusterDataVersions(
nodeId: NodeId,
filterEndpointId?: EndpointNumber,
filterClusterId?: ClusterId,
): Promise<{ endpointId: EndpointNumber; clusterId: ClusterId; dataVersion: number }[]> {
const peer = this.peers.get(this.fabric.addressOf(nodeId));
if (peer === undefined || peer.dataStore === undefined) {
return []; // We have no store, also no data
}
await peer.dataStore.construction;
return peer.dataStore.getClusterDataVersions(filterEndpointId, filterClusterId);
}
async retrieveStoredAttributes(
nodeId: NodeId,
endpointId: EndpointNumber,
clusterId: ClusterId,
): Promise<DecodedAttributeReportValue<any>[]> {
const peer = this.peers.get(this.fabric.addressOf(nodeId));
if (peer === undefined || peer.dataStore === undefined) {
return []; // We have no store, also no data
}
await peer.dataStore.construction;
return peer.dataStore.retrieveAttributes(endpointId, clusterId);
}
async updateFabricLabel(label: string) {
await this.fabric.setLabel(label);
}
}
class CommissionedNodeStore extends PeerAddressStore {
declare peers: PeerSet;
#controllerStore: ControllerStoreInterface;
constructor(
controllerStore: ControllerStoreInterface,
private fabric: Fabric,
) {
super();
this.#controllerStore = controllerStore;
}
async createNodeStore(address: PeerAddress, load = true) {
return new CachedClientNodeStore(await this.#controllerStore.clientNodeStore(address.nodeId.toString()), load);
}
async loadPeers() {
if (!(await this.#controllerStore.nodesStorage.has("commissionedNodes"))) {
return [];
}
const commissionedNodes =
await this.#controllerStore.nodesStorage.get<StoredOperationalPeer[]>("commissionedNodes");
const nodes = new Array<CommissionedPeer>();
for (const [nodeId, { operationalServerAddress, discoveryData, deviceData }] of commissionedNodes) {
const address = this.fabric.addressOf(nodeId);
nodes.push({
address,
operationalAddress: operationalServerAddress,
discoveryData,
deviceData,
dataStore: await this.createNodeStore(address),
} satisfies CommissionedPeer);
}
return nodes;
}
async updatePeer() {
return this.save();
}
async deletePeer(address: PeerAddress) {
await (await this.#controllerStore.clientNodeStore(address.nodeId.toString())).clearAll();
return this.save();
}
async save() {
await this.#controllerStore.nodesStorage.set(
"commissionedNodes",
this.peers.map(peer => {
const {
address,
operationalAddress: operationalServerAddress,
discoveryData,
deviceData,
} = peer as CommissionedPeer;
return [
address.nodeId,
{ operationalServerAddress, discoveryData, deviceData },
] satisfies StoredOperationalPeer;
}),
);
}
}