@project-chip/matter.js
Version:
Matter protocol in pure js
516 lines (515 loc) • 18 kB
JavaScript
/**
* @license
* Copyright 2022-2025 Matter.js Authors
* SPDX-License-Identifier: Apache-2.0
*/
import { GeneralCommissioning } from "#clusters";
import { CachedClientNodeStore } from "#device/CachedClientNodeStore.js";
import {
Bytes,
ChannelType,
Construction,
Crypto,
CRYPTO_SYMMETRIC_KEY_LENGTH,
Environment,
ImplementationError,
Logger,
MatterError,
NetInterfaceSet,
serverAddressToString,
StorageBackendMemory,
StorageManager
} from "#general";
import { LegacyControllerStore } from "#LegacyControllerStore.js";
import {
CertificateAuthority,
ChannelManager,
ClusterClient,
CommissioningError,
ControllerCommissioner,
DEFAULT_ADMIN_VENDOR_ID,
DEFAULT_FABRIC_ID,
DeviceAdvertiser,
ExchangeManager,
Fabric,
FabricBuilder,
FabricManager,
InteractionClientProvider,
NodeDiscoveryType,
PeerAddressStore,
PeerSet,
RetransmissionLimitReachedError,
SecureChannelProtocol,
SessionManager,
SubscriptionClient
} from "#protocol";
import {
EndpointNumber,
FabricId,
FabricIndex,
NodeId
} from "#types";
import { MessageChannel } from "@matter/protocol";
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");
class MatterController {
static async create(options) {
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 = void 0;
if (rootFabric !== void 0 || await fabricStorage.has("fabric")) {
const fabric = rootFabric ?? new Fabric(crypto, await fabricStorage.get("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 !== void 0) {
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 === void 0) {
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;
}
static async createAsPaseCommissioner(options) {
const {
certificateAuthorityConfig,
rootCertificateAuthority,
fabricConfig,
adminFabricLabel,
scanners,
netInterfaces,
sessionClosedCallback
} = options;
const crypto = options.crypto ?? Environment.default.get(Crypto);
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 === void 0 && certificateAuthorityConfig === void 0) {
throw new ImplementationError("Either rootCertificateAuthority or certificateAuthorityConfig must be set.");
}
const certificateManager = rootCertificateAuthority ?? await CertificateAuthority.create(crypto, certificateAuthorityConfig);
const storageManager = new StorageManager(new StorageBackendMemory());
await storageManager.initialize();
const fabric = new Fabric(crypto, fabricConfig);
const controller = new MatterController({
controllerStore: new LegacyControllerStore(storageManager.createContext("Commissioner")),
scanners,
netInterfaces,
certificateManager,
fabric,
adminFabricLabel,
sessionClosedCallback
});
await controller.construction;
return controller;
}
sessionManager;
netInterfaces = new NetInterfaceSet();
channelManager = new ChannelManager(CONTROLLER_CONNECTIONS_PER_FABRIC_AND_NODE);
exchangeManager;
peers;
clients;
commissioner;
#construction;
#store;
nodesStore;
scanners;
ca;
fabric;
sessionClosedCallback;
#advertiser;
get construction() {
return this.#construction;
}
constructor(options) {
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);
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);
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) {
return this.#advertiser.hasBroadcaster(broadcaster);
}
addBroadcaster(broadcaster) {
this.#advertiser.addBroadcaster(broadcaster);
}
async deleteBroadcaster(broadcaster) {
await this.#advertiser.deleteBroadcaster(broadcaster);
}
collectScanners(discoveryCapabilities = { onIpNetwork: true }) {
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, customizations) {
const commissioningOptions = {
...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) {
return this.peers.disconnect(this.fabric.addressOf(nodeId));
}
async connectPaseChannel(options) {
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) {
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, discoveryData) {
const interactionClient = await this.connect(
peerNodeId,
{
discoveryType: NodeDiscoveryType.TimedDiscovery,
timeoutSeconds: 120,
discoveryData
},
true
);
const generalCommissioningClusterClient = ClusterClient(
GeneralCommissioning.Cluster,
EndpointNumber(0),
interactionClient
);
const { errorCode, debugText } = await generalCommissioningClusterClient.commissioningComplete(void 0, {
useExtendedFailSafeMessageResponseTimeout: true
});
if (errorCode !== GeneralCommissioning.CommissioningError.Ok) {
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;
return {
nodeId: address.nodeId,
operationalAddress: operationalAddress ? serverAddressToString(operationalAddress) : void 0,
advertisedName: discoveryData?.DN,
discoveryData,
deviceData
};
});
}
getCommissionedNodeDetails(nodeId) {
const nodeDetails = this.peers.get(this.fabric.addressOf(nodeId));
if (nodeDetails === void 0) {
throw new Error(`Node ${nodeId} is not commissioned.`);
}
const { address, operationalAddress, discoveryData, deviceData } = nodeDetails;
return {
nodeId: address.nodeId,
operationalAddress: operationalAddress ? serverAddressToString(operationalAddress) : void 0,
advertisedName: discoveryData?.DN,
discoveryData,
deviceData
};
}
async enhanceCommissionedNodeDetails(nodeId, deviceData) {
const nodeDetails = this.peers.get(this.fabric.addressOf(nodeId));
if (nodeDetails === void 0) {
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, discoveryOptions, allowUnknownPeer) {
return this.clients.connect(this.fabric.addressOf(peerNodeId), { discoveryOptions, allowUnknownPeer });
}
createInteractionClient(peerNodeIdOrChannel, 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) {
return this.sessionManager.findResumptionRecordById(resumptionId);
}
findResumptionRecordByNodeId(nodeId) {
return this.sessionManager.findResumptionRecordByAddress(this.fabric.addressOf(nodeId));
}
async saveResumptionRecord(resumptionRecord) {
return this.sessionManager.saveResumptionRecord(resumptionRecord);
}
announce() {
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, filterEndpointId, filterClusterId) {
const peer = this.peers.get(this.fabric.addressOf(nodeId));
if (peer === void 0 || peer.dataStore === void 0) {
return [];
}
await peer.dataStore.construction;
return peer.dataStore.getClusterDataVersions(filterEndpointId, filterClusterId);
}
async retrieveStoredAttributes(nodeId, endpointId, clusterId) {
const peer = this.peers.get(this.fabric.addressOf(nodeId));
if (peer === void 0 || peer.dataStore === void 0) {
return [];
}
await peer.dataStore.construction;
return peer.dataStore.retrieveAttributes(endpointId, clusterId);
}
async updateFabricLabel(label) {
await this.fabric.setLabel(label);
}
}
class CommissionedNodeStore extends PeerAddressStore {
constructor(controllerStore, fabric) {
super();
this.fabric = fabric;
this.#controllerStore = controllerStore;
}
#controllerStore;
async createNodeStore(address, 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("commissionedNodes");
const nodes = new Array();
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)
});
}
return nodes;
}
async updatePeer() {
return this.save();
}
async deletePeer(address) {
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;
return [
address.nodeId,
{ operationalServerAddress, discoveryData, deviceData }
];
})
);
}
}
export {
MatterController
};
//# sourceMappingURL=MatterController.js.map