diff options
Diffstat (limited to 'src/commissioning.ts')
| -rw-r--r-- | src/commissioning.ts | 101 |
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 }; +} |
