src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/fabrics.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/fabrics.ts')
-rw-r--r--src/fabrics.ts153
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`);
+}