src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/commissioning.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/commissioning.ts')
-rw-r--r--src/commissioning.ts101
1 files changed, 101 insertions, 0 deletions
diff --git a/src/commissioning.ts b/src/commissioning.ts
new file mode 100644
index 0000000..b4edf9b
--- /dev/null
+++ b/src/commissioning.ts
@@ -0,0 +1,101 @@
+import { ManualPairingCodeCodec, QrPairingCodeCodec } from "@matter/main/types";
+import { GeneralCommissioning } from "@matter/main/clusters/general-commissioning";
+import type { CommissioningController, NodeCommissioningOptions } from "@project-chip/matter.js";
+import type { Config } from "./config.js";
+import { Log } from "./logging.js";
+import { serviceStatePath, updateNodeState } from "./state.js";
+
+const log = new Log("commissioning");
+
+export interface CommissioningResult {
+ nodeId: bigint;
+}
+
+interface ParsedPairingCode {
+ passcode: number;
+ shortDiscriminator?: number;
+ longDiscriminator?: number;
+}
+
+/**
+ * Parses an Apple-generated Matter multi-admin pairing code. Accepts the
+ * 11/21-digit manual code (with or without hyphens) or an "MT:" QR payload.
+ * The code is used only in memory and never logged or stored.
+ */
+export function parsePairingCode(code: string): ParsedPairingCode {
+ const trimmed = code.trim();
+ if (trimmed.length === 0) {
+ throw new Error("Pairing code is empty");
+ }
+
+ if (trimmed.toUpperCase().startsWith("MT:")) {
+ const [payload] = QrPairingCodeCodec.decode(trimmed);
+ if (payload === undefined) {
+ throw new Error("QR pairing code contains no payload");
+ }
+ return { passcode: payload.passcode, longDiscriminator: payload.discriminator };
+ }
+
+ const digits = trimmed.replace(/[-\s]/g, "");
+ if (!/^\d{11}$|^\d{21}$/.test(digits)) {
+ throw new Error("Manual pairing code must be 11 or 21 digits");
+ }
+ const decoded = ManualPairingCodeCodec.decode(digits);
+ return {
+ passcode: decoded.passcode,
+ shortDiscriminator: decoded.shortDiscriminator,
+ longDiscriminator: decoded.discriminator,
+ };
+}
+
+/**
+ * Commissions a device as an additional Matter administrator using on-network
+ * (IP) discovery only. The device stays joined to its existing primary fabric
+ * (e.g. Apple Home); no Thread credentials are involved.
+ *
+ * Any number of devices may be commissioned, one pairing code each; a device
+ * that is already on this fabric rejects the attempt itself (fabric
+ * conflict), so duplicates cannot occur.
+ */
+export async function commissionDevice(
+ controller: CommissioningController,
+ config: Config,
+ pairingCode: string,
+): Promise<CommissioningResult> {
+ const existing = controller.getCommissionedNodes();
+ if (existing.length > 0) {
+ log.info(
+ `${existing.length} node${existing.length === 1 ? "" : "s"} already commissioned; ` +
+ `adding another device to the same fabric.`,
+ );
+ }
+
+ const parsed = parsePairingCode(pairingCode);
+ const identifierData =
+ parsed.longDiscriminator !== undefined
+ ? { longDiscriminator: parsed.longDiscriminator }
+ : parsed.shortDiscriminator !== undefined
+ ? { shortDiscriminator: parsed.shortDiscriminator }
+ : {};
+
+ log.info("Discovering commissionable device on the IP network (_matterc._udp)...");
+
+ const options: NodeCommissioningOptions = {
+ commissioning: {
+ regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
+ regulatoryCountryCode: "XX",
+ },
+ discovery: {
+ identifierData,
+ discoveryCapabilities: { onIpNetwork: true },
+ },
+ passcode: parsed.passcode,
+ };
+
+ const nodeId = await controller.commissionNode(options, { connectNodeAfterCommissioning: false });
+ const nodeIdBigint = BigInt(nodeId);
+
+ updateNodeState(serviceStatePath(config.storagePath), nodeIdBigint, {});
+ log.info(`Commissioning complete: node ${nodeIdBigint}`);
+ return { nodeId: nodeIdBigint };
+}