diff options
Diffstat (limited to 'src/fabrics.ts')
| -rw-r--r-- | src/fabrics.ts | 153 |
1 files changed, 153 insertions, 0 deletions
diff --git a/src/fabrics.ts b/src/fabrics.ts new file mode 100644 index 0000000..2c4c90e --- /dev/null +++ b/src/fabrics.ts @@ -0,0 +1,153 @@ +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<number, string> = { + 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<FabricTable> { + 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 <index>`); + } + 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<void> { + 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`); +} |
