import { FabricIndex } from "@matter/main"; import { OperationalCredentials } from "@matter/main/clusters/operational-credentials"; import type { CommissioningController } from "@project-chip/matter.js"; import type { PairedNode } from "@project-chip/matter.js/device"; import { Log } from "./logging.js"; const log = new Log("fabrics"); /** * Fabric-table management on the device's Operational Credentials cluster. * * Every commissioning writes a fabric entry into the device's limited fabric * table. Entries never expire; identities whose local storage was deleted * leave orphans behind. These helpers make the table visible and allow an * admin (us) to remove stale entries without a factory reset. */ export interface FabricEntry { fabricIndex: number; fabricId: bigint; nodeId: bigint; vendorId: number; label: string; /** True when the entry belongs to this controller's current identity. */ isOurs: boolean; } export interface FabricTable { supportedFabrics: number; commissionedFabrics: number; entries: FabricEntry[]; } const KNOWN_VENDORS: Record = { 0x1349: "Apple", 0x6006: "Google", 0x10e1: "SmartThings", 0x117c: "IKEA", }; function vendorName(vendorId: number): string { const known = KNOWN_VENDORS[vendorId]; if (known !== undefined) return known; if (vendorId >= 0xfff1 && vendorId <= 0xfff4) return "test vendor"; return "unknown vendor"; } export async function readFabricTable( controller: CommissioningController, node: PairedNode, ): Promise { const client = node.getRootClusterClient(OperationalCredentials.Complete); if (client === undefined) { throw new Error("Operational Credentials cluster not found on the device's root endpoint"); } // Read from the device with fabric filtering off, so entries from all // fabrics are returned, not just our own. const fabrics = await client.attributes.fabrics.get(true, false); const supportedFabrics = (await client.attributes.supportedFabrics.get(true)) ?? 0; const commissionedFabrics = (await client.attributes.commissionedFabrics.get(true)) ?? 0; if (fabrics === undefined) { throw new Error("Device returned no fabric list"); } const ourFabric = controller.fabric; const entries = fabrics.map(fabric => ({ fabricIndex: Number(fabric.fabricIndex), fabricId: BigInt(fabric.fabricId), nodeId: BigInt(fabric.nodeId), vendorId: Number(fabric.vendorId), label: fabric.label, isOurs: ourFabric.matchesFabricIdAndRootPublicKey(fabric.fabricId, fabric.rootPublicKey), })); return { supportedFabrics, commissionedFabrics, entries }; } export function formatFabricTable(nodeId: bigint, table: FabricTable): string { const lines: string[] = []; lines.push( `Fabric table on node ${nodeId} (${table.commissionedFabrics} of ${table.supportedFabrics} slots used):`, ); for (const entry of table.entries) { const vendor = `0x${entry.vendorId.toString(16).padStart(4, "0")} (${vendorName(entry.vendorId)})`; const marker = entry.isOurs ? " [this controller]" : ""; lines.push( ` index ${entry.fabricIndex}: label ${JSON.stringify(entry.label)} vendor ${vendor} ` + `fabricId ${entry.fabricId} nodeId ${entry.nodeId}${marker}`, ); } const strays = findLikelyStrays(table); if (strays.length > 0) { lines.push(""); lines.push( `Likely stale entries (our label, but not our current identity): ` + `${strays.map(entry => `index ${entry.fabricIndex}`).join(", ")}`, ); lines.push(`Remove one with: mattertimesync fabrics --remove `); } return lines.join("\n"); } /** * Entries carrying our fabric label but not our current identity: orphans * from a commissioning whose local storage was deleted or replaced. */ export function findLikelyStrays(table: FabricTable): FabricEntry[] { const ourLabel = table.entries.find(entry => entry.isOurs)?.label; return table.entries.filter(entry => !entry.isOurs && ourLabel !== undefined && entry.label === ourLabel); } /** * Removes another fabric's entry from the device. Refuses to remove our own * entry; `decommission` is the correct path for that, because it also cleans * up local controller state. */ export async function removeFabricByIndex( controller: CommissioningController, node: PairedNode, fabricIndex: number, ): Promise { const table = await readFabricTable(controller, node); const entry = table.entries.find(candidate => candidate.fabricIndex === fabricIndex); if (entry === undefined) { throw new Error( `No fabric with index ${fabricIndex} on the device ` + `(present: ${table.entries.map(candidate => candidate.fabricIndex).join(", ")})`, ); } if (entry.isOurs) { throw new Error( `Fabric index ${fabricIndex} is this controller's own entry. ` + `Use "decommission" instead, so local state is cleaned up too.`, ); } const client = node.getRootClusterClient(OperationalCredentials.Complete); if (client === undefined) { throw new Error("Operational Credentials cluster not found on the device's root endpoint"); } log.info(`Removing fabric index ${fabricIndex} (label ${JSON.stringify(entry.label)}) from the device`); const response = await client.commands.removeFabric({ fabricIndex: FabricIndex(fabricIndex) }); if (response.statusCode !== OperationalCredentials.NodeOperationalCertStatus.Ok) { throw new Error( `Device rejected RemoveFabric for index ${fabricIndex}: ` + `status ${OperationalCredentials.NodeOperationalCertStatus[response.statusCode] ?? response.statusCode}` + `${response.debugText ? ` (${response.debugText})` : ""}`, ); } log.info(`Fabric index ${fabricIndex} removed`); }