src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/commissioning.ts
blob: b4edf9b8093495723c498be13628e516df32030b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
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 };
}