diff --git a/packages/cli-tool/package.json b/packages/cli-tool/package.json index 45e869b6dc..d780f74106 100644 --- a/packages/cli-tool/package.json +++ b/packages/cli-tool/package.json @@ -47,6 +47,7 @@ "@matter/protocol": "*", "@matter/tools": "*", "@matter/types": "*", + "@matter/nodejs": "*", "@types/escodegen": "^0.0.10", "acorn": "^8.13.0", "ansi-colors": "^4.1.3", diff --git a/packages/cli-tool/src/cli.ts b/packages/cli-tool/src/cli.ts index dc277f6485..d3e8d4c6b6 100644 --- a/packages/cli-tool/src/cli.ts +++ b/packages/cli-tool/src/cli.ts @@ -5,6 +5,7 @@ */ import { repl } from "#repl.js"; +import "@matter/nodejs"; import colors from "ansi-colors"; import { stdout } from "process"; import yargs from "yargs"; diff --git a/packages/cli-tool/src/commands/cd.ts b/packages/cli-tool/src/commands/cd.ts index 3afd68c750..d9d963d22c 100644 --- a/packages/cli-tool/src/commands/cd.ts +++ b/packages/cli-tool/src/commands/cd.ts @@ -9,12 +9,12 @@ import { Command } from "./command.js"; Command({ usage: "[PATH]", - description: "Change current working directory. If you omit PATH changes to the root directory.", + description: "Change current working directory. If you omit PATH changes to the last node entered.", maxArgs: 1, invoke: async function cd([path]) { if (path === undefined) { - path = "/"; + path = this.env.vars.get("home", "/"); } else { path = `${path}`; } @@ -26,5 +26,7 @@ Command({ } this.location = location; + + await this.env.vars.persist("cwd", location.path); }, }); diff --git a/packages/cli-tool/src/commands/clear.ts b/packages/cli-tool/src/commands/clear.ts new file mode 100644 index 0000000000..adb6f0362e --- /dev/null +++ b/packages/cli-tool/src/commands/clear.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Command } from "./command.js"; + +Command({ + description: "Clear the terminal screen", + + invoke: function clear() { + console.clear(); + }, +}); diff --git a/packages/cli-tool/src/commands/help.ts b/packages/cli-tool/src/commands/help.ts index 107d13ab7d..7ae5b92d48 100644 --- a/packages/cli-tool/src/commands/help.ts +++ b/packages/cli-tool/src/commands/help.ts @@ -26,9 +26,9 @@ Command({ if (path === undefined) { const HELP = `This tool allows you to interact with matter.js and your local Matter environment. -This tool understands both JavaScript and a shell-like syntax that maps "commands" to functions. Type ${quote("ls bin")} to see commands you can always use. Type ${quote("help ")} for help with a specific command. +This tool understands both JavaScript and a shell-like syntax that maps "commands" to functions. Type ${quote("ls /bin")} to see commands you can always use. Type ${quote("help ")} for help with a specific command. The current path appears in the prompt. This points to the object this tool uses to find commands. It is also the "global" object for any JavaScript statements you enter. -You can change the current path using ${quote("cd ")}. Paths work like you would expect, including ${quote("/")}, ${quote(".")} and ${quote("..")}.`; +You can change the current path using ${quote("cd ")}. Paths work like you would expect, including ${quote("/")}, ${quote(".")} and ${quote("..")}.\n`; this.out( "\nWelcome to ", @@ -44,7 +44,7 @@ You can change the current path using ${quote("cd ")}. Paths work like yo const what = await this.searchPathFor(pathStr); if (what.kind !== "command") { - this.out(`${path} is a ${what} but we can't tell you much more about it.`); + this.out(`${path} is a ${what} but we can't tell you much more about it.\n\n`); return; } @@ -56,15 +56,23 @@ You can change the current path using ${quote("cd ")}. Paths work like yo let ast; try { - ast = parse(`${what.definition}`, { ecmaVersion: "latest" }).body[0]; + ast = parse(`${what.definition}`, { ecmaVersion: "latest", checkPrivateFields: false }).body[0]; } catch (e) { if (!(e instanceof SyntaxError)) { throw e; } + try { + ast = parse(`function ${what.definition}`, { ecmaVersion: "latest", checkPrivateFields: false }) + .body[0]; + } catch (e) { + if (!(e instanceof SyntaxError)) { + throw e; + } + } } if (ast?.type !== "FunctionDeclaration") { - this.out(`\nWell, ${colors.blue(pathStr)} is a function but we can't seem to parse it.`); + this.out(`\nWell, ${colors.blue(pathStr)} is a function but we can't seem to parse it.\n\n`); return; } @@ -74,7 +82,7 @@ You can change the current path using ${quote("cd ")}. Paths work like yo } this.out( - `\nUsage: ${usage.join(" ")}\n\n${colors.blue(pathStr)} is a function that does not provide additional usage details.\n`, + `\nUsage: ${usage.join(" ")}\n\n${colors.blue(pathStr)} is a function that does not provide additional usage details.\n\n`, ); }, }); diff --git a/packages/cli-tool/src/commands/index.ts b/packages/cli-tool/src/commands/index.ts index e861d07c8c..0b687ed527 100644 --- a/packages/cli-tool/src/commands/index.ts +++ b/packages/cli-tool/src/commands/index.ts @@ -6,6 +6,7 @@ import "./cat.js"; import "./cd.js"; +import "./clear.js"; import "./exit.js"; import "./help.js"; import "./ls.js"; diff --git a/packages/cli-tool/src/commands/ls.ts b/packages/cli-tool/src/commands/ls.ts index 1caea4fe00..f392a68244 100644 --- a/packages/cli-tool/src/commands/ls.ts +++ b/packages/cli-tool/src/commands/ls.ts @@ -16,6 +16,7 @@ Command({ flagArgs: { a: "show hidden properties", l: "use a long listing format", + d: "list directories themselves, not their contents", }, invoke: async function ls(args, flags) { @@ -30,7 +31,7 @@ Command({ if (locations.length) { for (const location of locations) { - if (location.kind === "directory") { + if (location.kind === "directory" && !flags.d) { dirs.push(location); } else { files.push(location); @@ -169,6 +170,14 @@ function formatName(location: DisplayLocation) { return { name: `${colors.green(name)}*`, length: length + 1 }; } + if (location.tag === "constructor") { + return { name: `${colors.green.bold(name)}*`, length: length + 1 }; + } + + if (location.tag === "event") { + return { name: `${colors.yellow(name)}=`, length: length + 1 }; + } + return { name, length }; } @@ -240,8 +249,14 @@ function DisplayLocation(location: Location, all: boolean, displayName?: string) function listPaths(paths: string[], definition: unknown) { const result = new Set(paths); if (all && typeof definition === "object" && definition !== null) { - for (const key of Object.getOwnPropertyNames(definition)) { - result.add(key); + for ( + let obj = definition; + obj !== undefined && obj !== null && obj !== Object.prototype; + obj = Object.getPrototypeOf(obj) + ) { + for (const key of Object.getOwnPropertyNames(obj)) { + result.add(key); + } } } diff --git a/packages/cli-tool/src/commands/set.ts b/packages/cli-tool/src/commands/set.ts index 193e7073aa..19abed6713 100644 --- a/packages/cli-tool/src/commands/set.ts +++ b/packages/cli-tool/src/commands/set.ts @@ -10,7 +10,7 @@ import { Command } from "./command.js"; Command({ usage: ["", "KEY=VALUE", "KEY VALUE"], description: - 'Set or display environment variables. matter.js defines variables in a hierarchy with "." as a delimiter.', + 'Set or display environment variables. matter.js defines variables in a hierarchy with "." as a delimiter. Variables persist across restarts.', maxArgs: 2, invoke: async function set(args) { @@ -24,11 +24,11 @@ Command({ if (equalPos === -1) { this.err("Invalid argument: parameter must be of the form key=value"); } - this.env.vars.set(assignment.slice(0, equalPos), assignment.slice(equalPos + 1)); + await this.env.vars.persist(assignment.slice(0, equalPos), assignment.slice(equalPos + 1)); break; case 2: - this.env.vars.set(`${args[0]}`, args[1] as VariableService.Value); + await this.env.vars.persist(`${args[0]}`, args[1] as VariableService.Value); break; } }, diff --git a/packages/cli-tool/src/domain.ts b/packages/cli-tool/src/domain.ts index dcc9f17d31..fc472d284a 100644 --- a/packages/cli-tool/src/domain.ts +++ b/packages/cli-tool/src/domain.ts @@ -5,11 +5,20 @@ */ import { BadCommandError, IncompleteError, NotACommandError, NotADirectoryError, NotFoundError } from "#errors.js"; -import { Environment, InternalError, MaybePromise } from "#general"; +import { + CancelablePromise, + Diagnostic, + Environment, + InternalError, + LogFormat, + MaybePromise, + Observable, +} from "#general"; import { bin, globals as defaultGlobals } from "#globals.js"; import { Location, undefinedValue } from "#location.js"; import { parseInput } from "#parser.js"; import { Directory } from "#stat.js"; +import { ServerNode } from "@matter/node"; import colors from "ansi-colors"; import { inspect } from "util"; import { createContext, runInContext, RunningCodeOptions } from "vm"; @@ -18,6 +27,19 @@ export interface TextWriter { (...text: string[]): void; } +const GLOBALS: Record = { + general: "@matter/general", + tools: "@matter/tools", + protocol: "@matter/protocol", + node: "@matter/node", + types: "@matter/types", + model: "@matter/model", + clusters: "@matter/types/clusters", + behaviors: "@matter/node/behaviors", + endpoints: "@matter/node/endpoints", + devices: "@matter/node/devices", +}; + /** * Interfaces {@link Domain} with other components. * @@ -39,12 +61,18 @@ export interface Domain extends DomainContext { execute(input: string): Promise; searchPathFor(name: string): Promise; inspect(what: unknown): string; + displayError(cause: unknown, prefix?: string): void; + displayHint(message: string): void; + interrupt(): void; + interrupted: Observable<[], false | void>; + globals: Record; + globalsLoaded: Promise; } /** * Maintains state and executes commands. */ -export function Domain(context: DomainContext): Domain { +export async function Domain(context: DomainContext): Promise { const hiddenGlobals = Object.keys(globalThis); const globals: Record = Object.defineProperties( @@ -61,6 +89,10 @@ export function Domain(context: DomainContext): Domain { }, ); + let softInterrupted = false; + let cancelEval: undefined | (() => void); + let discardEval: undefined | (() => void); + Object.defineProperty(globals.bin, "domain", { get() { return domain; @@ -71,6 +103,10 @@ export function Domain(context: DomainContext): Domain { globals.global = globals.globalThis = globals; + const defaultNode = new ServerNode(); + await defaultNode.construction; + globals[defaultNode.id] = defaultNode; + const domain: Domain = { isDomain: true, @@ -103,55 +139,7 @@ export function Domain(context: DomainContext): Domain { undefined, ), - async execute(inputStr: string) { - const input = parseInput(inputStr); - - switch (input.kind) { - case "empty": - return; - - case "incomplete": - throw new IncompleteError(input.error); - - case "command": - break; - - case "statement": - return await evaluate(input.js); - - default: - throw new InternalError(`Unknown internal command type ${(input as any).kind}`); - } - - const { name, args } = input; - - const location = await this.searchPathFor(name); - - const fn = location?.definition; - - if (location === undefined || fn === undefined) { - throw new NotACommandError(name); - } - - if (typeof fn !== "function") { - if (args.length) { - // If there are arguments it must be a call; otherwise it's just inspection - throw new BadCommandError(name); - } - return fn; - } - - const argvals = args.map(arg => { - return evaluate(arg.js, { - lineOffset: arg.line - 1, - columnOffset: arg.column, - }); - }); - - const scope = location.parent?.definition ?? globals; - - return fn.apply(scope, argvals); - }, + execute, async searchPathFor(name: string) { let location; @@ -180,9 +168,63 @@ export function Domain(context: DomainContext): Domain { if (value === undefinedValue) { return colors.dim("(undefined)"); } + + if ( + typeof value === "object" && + value !== null && + (Diagnostic.value in value || Diagnostic.presentation in value || value instanceof Error) + ) { + return LogFormat[colors.enabled ? "ansi" : "plain"](value); + } + return inspect(value, false, 1, this.colorize); }, + displayError(cause: unknown, prefix?: string) { + const formatted = this.inspect(cause); + + if (prefix) { + prefix = colors.dim(`${prefix}: `); + } else { + prefix = ""; + } + + this.err(`${prefix}${formatted}\n`); + }, + + displayHint(message: string) { + this.err(`${colors.dim(colors.whiteBright(message))}\n`); + }, + + interrupt() { + if (this.interrupted.emit() === false) { + softInterrupted = false; + return; + } + + if (cancelEval) { + cancelEval(); + cancelEval = undefined; + softInterrupted = false; + } else if (discardEval) { + discardEval(); + discardEval = undefined; + softInterrupted = false; + this.displayHint("Ignoring your command (it may continue running)"); + } else if (softInterrupted) { + if (this.exitHandler) { + MaybePromise.catch(this.exitHandler.bind(this), cause => + this.displayError(cause, "Error triggered in exit handler"), + ); + } else { + this.err(colors.red("Cannot abort process because there is no exit handler")); + } + } else { + this.displayHint("Press control-c again to exit"); + softInterrupted = true; + } + }, + get description() { return context.description; }, @@ -206,15 +248,30 @@ export function Domain(context: DomainContext): Domain { get env() { return context.env; }, + + interrupted: Observable(), + + globals, + globalsLoaded: undefined as unknown as Promise, }; + domain.globalsLoaded = loadGlobals(domain); + + if (!domain.env.vars.has("home")) { + domain.env.vars.set("home", `/${defaultNode.id}`); + } + const vmContext = createContext( new Proxy( {}, { get(_target, key, _receiver) { if (key in (domain.location.definition as {})) { - return (domain.location.definition as any)[key]; + let result = (domain.location.definition as any)[key]; + if (typeof result === "function") { + result = result.bind(domain.location.definition); + } + return result; } return globals[key as any]; @@ -274,8 +331,115 @@ export function Domain(context: DomainContext): Domain { ), ); + const cwd = domain.env.vars.string("cwd"); + if (cwd !== undefined) { + try { + domain.location = await domain.location.at(cwd); + } catch (e) { + if (!(e instanceof NotFoundError) && !(e instanceof NotADirectoryError)) { + throw e; + } + } + } + + domain.env.runtime.interrupt = () => { + domain.interrupt(); + return true; + }; + return domain; + async function execute(inputStr: string) { + softInterrupted = false; + + const input = parseInput(inputStr); + + switch (input.kind) { + case "empty": + return; + + case "incomplete": + throw new IncompleteError(input.error); + + case "command": + break; + + case "statement": + return await interruptablePromiseOf(evaluate(input.js)); + + default: + throw new InternalError(`Unknown internal command type ${(input as any).kind}`); + } + + const { name, args } = input; + + const location = await interruptablePromiseOf(domain.searchPathFor(name)); + + const fn = location?.definition; + + if (location === undefined || fn === undefined) { + throw new NotACommandError(name); + } + + if (typeof fn !== "function") { + if (args.length) { + // If there are arguments it must be a call; otherwise it's just inspection + throw new BadCommandError(name); + } + return fn; + } + + const argvals = args.map(arg => { + return evaluate(arg.js, { + lineOffset: arg.line - 1, + columnOffset: arg.column, + }); + }); + + const scope = location.parent?.definition ?? globals; + + return await interruptablePromiseOf(fn.apply(scope, argvals)); + + async function interruptablePromiseOf(result: MaybePromise) { + if (!MaybePromise.is(result)) { + return result; + } + + // Do not await Construction or Observable + if ("emit" in result || "change" in result) { + return result; + } + + try { + if (CancelablePromise.is(result)) { + cancelEval = result.cancel.bind(result); + } + + let isDiscarded = false; + const discarded = new Promise(resolve => { + discardEval = () => { + isDiscarded = true; + resolve(); + }; + }); + + const returnValue = await Promise.race([discarded, result]); + + if (isDiscarded) { + result.then( + () => domain.displayHint("Ignored command has finished"), + cause => domain.displayError(cause, "Ignored command crashed"), + ); + } + + return returnValue; + } finally { + cancelEval = discardEval = undefined; + softInterrupted = false; + } + } + } + function evaluate(js: string, options: RunningCodeOptions = {}) { return runInContext(js, vmContext, { breakOnSigint: true, @@ -285,3 +449,21 @@ export function Domain(context: DomainContext): Domain { }); } } + +/** + * We load the global packages dynamically, attempting to get interpreter to start faster. + */ +export async function loadGlobals(domain: Domain) { + const loads = Array>(); + + for (const name in GLOBALS) { + loads.push( + import(GLOBALS[name]).then( + module => (domain.globals[name] = module), + error => domain.displayError(`Error loading ${GLOBALS[name]}: ${error.message}`), + ), + ); + } + + await Promise.allSettled(loads); +} diff --git a/packages/cli-tool/src/globals.ts b/packages/cli-tool/src/globals.ts index 8bad677da2..1f796b39ab 100644 --- a/packages/cli-tool/src/globals.ts +++ b/packages/cli-tool/src/globals.ts @@ -5,13 +5,7 @@ */ import type { Domain } from "#domain.js"; -import * as general from "#general"; -import * as model from "#model"; import { Matter as matter } from "#model"; -import * as node from "#node"; -import * as protocol from "#protocol"; -import * as tools from "#tools"; -import * as types from "#types"; export interface DomainCommand { (domain: Domain, ...args: unknown[]): unknown; @@ -21,12 +15,6 @@ export interface DomainCommand { export const bin: Record = {}; export const globals: Record = { - general, - tools, - protocol, - node, - types, matter, - model, bin, }; diff --git a/packages/cli-tool/src/location.ts b/packages/cli-tool/src/location.ts index 4df90b66e4..82f0525152 100644 --- a/packages/cli-tool/src/location.ts +++ b/packages/cli-tool/src/location.ts @@ -33,8 +33,11 @@ export interface Location { maybeAt(path: string | number, searchedAs?: string): MaybePromise; } -function isClass(fn: {}) { - return !Object.getOwnPropertyDescriptor(fn, "prototype")?.writable; +/** + * Attempt to differentiate between functions and classes. Not really possible so this is just a heuristic. + */ +function isConstructor(fn: {}) { + return fn.toString().startsWith("class"); } export function Location(basename: string, definition: unknown, stat: Stat, parent: undefined | Location): Location { @@ -54,11 +57,14 @@ export function Location(basename: string, definition: unknown, stat: Stat, pare tag = "bytes"; } else if (definition.constructor.name !== "Object") { tag = definition.constructor.name; + if (tag === "BasicObservable") { + tag = "event"; + } } else { tag = "object"; } } else if (typeof definition === "function") { - if (isClass(definition)) { + if (isConstructor(definition)) { tag = "constructor"; } else { tag = "function"; @@ -71,7 +77,7 @@ export function Location(basename: string, definition: unknown, stat: Stat, pare tag = decamelize(tag); return { - kind: typeof definition === "function" && !isClass(definition) ? "command" : stat.kind, + kind: typeof definition === "function" && !isConstructor(definition) ? "command" : stat.kind, basename, name: stat.name, summary: stat.summary, @@ -155,7 +161,15 @@ export function Location(basename: string, definition: unknown, stat: Stat, pare } const subsearchedAs = searchedAs ? Location.join(searchedAs, segments[0]) : segments[0]; - const definition = stat.definitionAt(decodeURIComponent(segments[0])); + const name = decodeURIComponent(segments[0]); + let definition = stat.definitionAt(decodeURIComponent(name)); + + if (definition === undefined && typeof this.definition === "object" && this.definition !== null) { + definition = (this.definition as Record)[name]; + if (definition === undefined && name in this.definition) { + definition = undefinedValue; + } + } const accept = (definition: unknown) => { if (definition === undefined || definition === null) { @@ -173,11 +187,16 @@ export function Location(basename: string, definition: unknown, stat: Stat, pare return sublocation.at(segments.slice(1).join("/"), subsearchedAs); }; - if (MaybePromise.is(definition)) { - return definition.then(accept); + if (!MaybePromise.is(definition)) { + return accept(definition); + } + + // Do not await Construction or Observable + if ("emit" in definition || "change" in definition) { + return accept(definition); } - return accept(definition); + return definition.then(accept); }, }; } diff --git a/packages/cli-tool/src/parser.ts b/packages/cli-tool/src/parser.ts index 8c77ae4d02..8657e9098a 100644 --- a/packages/cli-tool/src/parser.ts +++ b/packages/cli-tool/src/parser.ts @@ -227,6 +227,8 @@ export namespace isCommand { "true", "false", "this", + "typeof", + "await", ]; const IDENTIFIER = "[\\p{L}$_][\\p{L}$_0-9]*"; diff --git a/packages/cli-tool/src/providers/endpoint.ts b/packages/cli-tool/src/providers/endpoint.ts index 528c86f028..d03224270d 100644 --- a/packages/cli-tool/src/providers/endpoint.ts +++ b/packages/cli-tool/src/providers/endpoint.ts @@ -4,15 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** - * @license - * Copyright 2022-2024 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Endpoint } from "#node"; +import { ClientNodes, Endpoint, ServerNode } from "#node"; import { Directory, Stat } from "#stat.js"; +const STATIC = new Set(["act", "start", "factoryReset", "run", "set", "visit", "nodes", "parts", "behaviors"]); + +const BEHAVIOR_PREFIX = "behavior_"; +const PART_PREFIX = "part_"; +const NODE_PREFIX = "node_"; + Stat.provide(endpoint => { if (!isEndpoint(endpoint)) { return; @@ -20,21 +20,57 @@ Stat.provide(endpoint => { return Directory({ definitionAt(path) { - const part = endpoint.parts.get(path); + if (STATIC.has(path)) { + return (endpoint as unknown as Record)[path]; + } - if (part) { - if (!part.lifecycle.isReady) { - return part.construction.then(() => Stat.of(part)); + const nodes = (endpoint as ServerNode).nodes as ClientNodes | undefined; + + if (path.startsWith(BEHAVIOR_PREFIX)) { + const id = path.slice(BEHAVIOR_PREFIX.length); + + // TODO - use act() to obtain an agent when evaluating in node context + const behavior = endpoint.behaviors.supported[id]; + if (behavior) { + return behavior; + } + } else if (path.startsWith(PART_PREFIX)) { + const id = path.slice(PART_PREFIX.length); + const part = endpoint.parts.get(id); + if (part) { + return part; } - return Stat.of(part); + } else if (nodes && path.startsWith(NODE_PREFIX)) { + const id = path.slice(NODE_PREFIX.length); + const node = nodes.get(id); + if (node) { + return node; + } + } + + // TODO - see comment above + const behavior = endpoint.behaviors.supported[path]; + if (behavior) { + return behavior; + } + + const part = endpoint.parts.get(path); + if (part) { + return part; + } + + const node = nodes?.get(path); + if (node) { + return node; } }, paths() { if (!endpoint.lifecycle.isPartsReady) { - return endpoint.construction.then(() => endpoint.parts.map(part => part.id)); + return endpoint.construction.then(listPaths); } - return endpoint.parts.map(part => part.id); + + return listPaths(endpoint); }, }); }); @@ -42,3 +78,41 @@ Stat.provide(endpoint => { function isEndpoint(item: unknown): item is Endpoint { return item instanceof Endpoint; } + +function listPaths(endpoint: Endpoint) { + const paths = new Set(); + + for (const command of STATIC) { + if ((endpoint as any)[command] !== undefined) { + paths.add(command); + } + } + + for (const basename in endpoint.behaviors.supported) { + if (paths.has(basename)) { + continue; + } + paths.add(basename); + } + + for (const part of endpoint.parts) { + let basename = part.id; + if (paths.has(basename)) { + basename = `${PART_PREFIX}${basename}`; + } + paths.add(basename); + } + + const nodes = (endpoint as ServerNode).nodes; + if (nodes !== undefined) { + for (const node of nodes) { + let basename = node.id; + if (paths.has(basename)) { + basename = `${NODE_PREFIX}${basename}`; + } + paths.add(basename); + } + } + + return [...paths]; +} diff --git a/packages/cli-tool/src/repl.ts b/packages/cli-tool/src/repl.ts index 420e776aab..6c0a19726c 100644 --- a/packages/cli-tool/src/repl.ts +++ b/packages/cli-tool/src/repl.ts @@ -6,27 +6,55 @@ import { Domain } from "#domain.js"; import { IncompleteError } from "#errors.js"; -import { Diagnostic, Environment, LogFormat } from "#general"; -import { undefinedValue } from "#location.js"; +import { Environment, InternalError, Observable, RuntimeService, StorageService, Time } from "#general"; import { isCommand } from "#parser.js"; import colors from "ansi-colors"; import { readFile } from "fs/promises"; import { homedir } from "os"; import { dirname, join } from "path"; -import { env, exit, stderr, stdout } from "process"; -import { AsyncCompleter, CompleterResult } from "readline"; -import { Recoverable, REPLEval, REPLServer, start } from "repl"; +import { exit, stderr, stdout } from "process"; +import { AsyncCompleter, CompleterResult, Key } from "readline"; +import { Recoverable, REPLEval, ReplOptions, REPLServer, start } from "repl"; +import { Context } from "vm"; import "./commands/index.js"; import "./providers/index.js"; +/** + * Present the user with a REPL for executing repeated commands. + * + * Logic related to command execution is in {@link Domain}. This is just a terminal-based UI based on Node's + * REPLServer. + */ +export async function repl() { + const domain = await createDomain(); + await domain.globalsLoaded; + stdout.write(`Welcome to ${domain.description}. Type ${colors.bold('"help"')} for help.\n`); + + const repl = createNodeRepl(domain); + configureInterruptHandling(repl); + installEvaluationInputBuffer(repl); + configureHistory(repl); + addCompletionSupport(repl); + instrumentReplToMaintainPrompt(repl); + instrumentReplToAddLineProtector(repl); + configureSpinner(repl); +} + +// Maybe worth sharing spinner implementation with tools. Maybe not +const SPINNER = "◐◓◑◒"; +const SPINNER_INTERVAL = 100; + // Node.js repl implementation does good stuff for us so want to keep it but we don't want the "." commands and it has // no way to disable those. So use this prefix as a hack to prevent it from noticing lines that start with "." const LINE_PROTECTOR_CHAR = "\u0001"; -export async function repl() { +/** + * Create our "domain" object that manages overall CLI state. + */ +async function createDomain() { const description = `${colors.bold("matter.js")} ${await readPackageVersion()}`; - const domain = Domain({ + const domain = await Domain({ description, out(...text) { @@ -58,116 +86,317 @@ export async function repl() { exit(0); }; - let server: REPLServer | undefined = undefined; - - const doEval: REPLEval = function (this, evalCmd, _context, _file, cb: (err: Error | null, result: any) => void) { - // See comment below r.e. "realEmit". We can't just strip first character because the line protector will - // appear multiple times if there are multiple lines - evalCmd = evalCmd.replace(new RegExp(LINE_PROTECTOR_CHAR, "g"), ""); + return domain; +} - if (evalCmd.endsWith("\n")) { - evalCmd = evalCmd.slice(0, evalCmd.length - 1); +async function readPackageVersion() { + let path = new URL(import.meta.url).pathname; + while (dirname(path) !== path) { + path = dirname(path); + try { + const pkg = await readFile(join(path, "package.json")); + const parsed = JSON.parse(pkg.toString()); + if (typeof parsed.version === "string") { + return parsed.version; + } + } catch (e) { + if ((e as any).code === "ENOENT") { + continue; + } + throw e; } - const result: Promise = domain.execute(evalCmd); - result.then(handleSuccess, handleError); + } - function handleSuccess(result: unknown) { - server?.setPrompt(createPrompt()); - if (result === undefinedValue) { - domain.out(domain.inspect(result)); - cb(null, undefined); - } - cb(null, result); + return "?"; +} + +interface KeypressEvent { + str: string; + key: Key; +} + +interface AugmentedRepl extends REPLServer { + // Node has an internal "domain" variable so name ours differently + mdomain: Domain; + + // Emits when we start/stop evaluating + evaluationModeChange: Observable<[state: boolean]>; + + // Emits when we receive input from the terminal + keypressReceived: Observable<[event: KeypressEvent], false | void>; + + // Emits before we pass input from the terminal to readline + keypressDelivering: Observable<[event: KeypressEvent]>; + + // Emits after we pass input from the terminal to readline + keypressDelivered: Observable<[event: KeypressEvent]>; + + // Emits when there is output to the console + outputDisplayed: Observable<[]>; + + // Injects data as if it was received from the terminal + deliverKeypress(event: KeypressEvent): void; + + // Indicates whether we believe cursor is on a new line + onNewline: boolean; + + // Indicates whether we are building a multiline command + inMultilineCommand: boolean; + + // Indicates output is prompt related so prompt management should ignore + updatingPrompt: boolean; +} + +/** + * Create an (augmented) node repl. + */ +function createNodeRepl(domain: Domain) { + const repl = start({ + prompt: createPrompt(domain), + eval: evaluate as REPLEval, + ignoreUndefined: true, + historySize: domain.env.vars.integer("history.size", 10000), + } as ReplOptions) as AugmentedRepl; + + repl.mdomain = domain; + repl.evaluationModeChange = Observable(); + repl.keypressReceived = Observable(); + repl.keypressDelivering = Observable(); + repl.keypressDelivered = Observable(); + repl.outputDisplayed = Observable(); + repl.onNewline = true; + repl.inMultilineCommand = repl.updatingPrompt = false; + + const onkeypress = repl.input.listeners("keypress").find(listener => listener.name === "onkeypress"); + if (!onkeypress) { + throw new InternalError("Could not identify REPL keypress listener"); + } + repl.input.off("keypress", onkeypress as any); + + repl.deliverKeypress = (event: KeypressEvent) => { + repl.keypressDelivering.emit(event); + onkeypress(event.str, event.key); + repl.keypressDelivered.emit(event); + }; + + repl.input.on("keypress", (str: string, key: Key) => { + const event = { str, key }; + + if (repl.keypressReceived.emit(event) === false) { + return; } - function handleError(error: Error) { - server?.setPrompt(createPrompt()); + repl.deliverKeypress(event); + }); + + return repl; +} - if (error.constructor.name === "IncompleteError") { - cb(new Recoverable((error as IncompleteError).cause as Error), undefined); - return; +/** + * The standard "eval" callback for the node repl. + */ +function evaluate( + this: AugmentedRepl, + evalCmd: string, + _context: Context, + _file: string, + cb: (err: Error | null, result: any) => void, +) { + this.inMultilineCommand = false; + this.evaluationModeChange.emit(true); + + // See comment below r.e. "realEmit". We can't just strip first character because the line protector will + // appear multiple times if there are multiple lines + evalCmd = evalCmd.replace(new RegExp(LINE_PROTECTOR_CHAR, "g"), ""); + + if (evalCmd.endsWith("\n")) { + evalCmd = evalCmd.slice(0, evalCmd.length - 1); + } + const result: Promise = this.mdomain.execute(evalCmd); + + const finish = (err: Error | null, result: any) => { + cb(err, result); + this.evaluationModeChange.emit(false); + }; + + const handleSuccess = (result: unknown) => { + try { + this.setPrompt(createPrompt(this.mdomain)); + if (result !== undefined) { + this.mdomain.out(`${this.mdomain.inspect(result)}\n`); } + } finally { + finish(null, undefined); + } + }; - // Stack frames following our special matter-cli-* "filenames" are just cruft. And if the first filename - // then just remove the stack and place location at end of message - const stack = error.stack; - if (stack !== undefined) { - const lines = stack.split("\n"); - let specialLoc: string | undefined; - let specialLine; - if ("isCliError" in error) { - // These are thrown at the top level and should not display a stack trace - specialLine = 1; - } else { - // Look for the "matter-cli-" marker which we prefix on the "filename" - specialLine = lines.findIndex(line => { - const match = line.match(/at matter-cli-(?:[a-z]+):([0-9]+:[0-9]+)?/); - if (match) { - specialLoc = match[1]; - return true; - } - }); - } + const handleError = (error: Error) => { + this.setPrompt(createPrompt(this.mdomain)); + + if (error.constructor.name === "IncompleteError") { + this.inMultilineCommand = true; + finish(new Recoverable((error as IncompleteError).cause as Error), undefined); + return; + } - if (specialLine === 1) { - if (specialLoc) { - error.message += ` (${specialLoc})`; + // Stack frames following our special matter-cli-* "filenames" are just cruft. And if the first filename + // then just remove the stack and place location at end of message + const stack = error.stack; + if (stack !== undefined) { + const lines = stack.split("\n"); + let specialLoc: string | undefined; + let specialLine; + if ("isCliError" in error) { + // These are thrown at the top level and should not display a stack trace + specialLine = 1; + } else { + // Look for the "matter-cli-" marker which we prefix on the "filename" + specialLine = lines.findIndex(line => { + const match = line.match(/at matter-cli-(?:[a-z]+):([0-9]+:[0-9]+)?/); + if (match) { + specialLoc = match[1]; + return true; } - error.stack = `${error.constructor.name}: ${error.message}`; - } else if (specialLine !== -1) { - error.stack = lines.slice(0, specialLine + 1).join("\n"); + }); + } + + if (specialLine === 1) { + if (specialLoc) { + error.message += ` (${specialLoc})`; } + error.stack = `${error.constructor.name}: ${error.message}`; + } else if (specialLine !== -1) { + error.stack = lines.slice(0, specialLine + 1).join("\n"); } + } - // Display the error ourselves so is pretty and captures all details - const diagnostic = Diagnostic.error(error); - const formatted = LogFormat[colors.enabled ? "ansi" : "plain"](diagnostic); - stderr.write(`${formatted}\n`); + // Display the error ourselves so is pretty and captures all details + this.mdomain.displayError(error); - // Do not report the error to node - cb(null, undefined); - } + // Do not report the error to node + finish(null, undefined); }; - stdout.write(`Welcome to ${description}. Type ${colors.bold('"help"')} for help.\n`); - server = start({ - prompt: createPrompt(), - eval: doEval, - ignoreUndefined: true, + result.then(handleSuccess, handleError); +} + +function createPrompt(domain: Domain) { + return `${colors.dim("matter")} ${colors.yellow(domain.location.path)} ❯ `; +} + +/** + * Bypass Node's interrupt handling and install our own. + */ +function configureInterruptHandling(repl: AugmentedRepl) { + repl.keypressReceived.on(event => { + // Ignore anything other than ctrl-c + if (!event.key.ctrl || event.key.name !== "c") { + return; + } + + // This is the same mechanism used by real SIGINTs so behavior is uniform + repl.mdomain.env.get(RuntimeService).interrupt(); + + // Do not pass ctrl-c to readline/REPLServer; this disables its interrupt handling + return false; + }); + + let evaluating = false; + repl.evaluationModeChange.on(mode => { + evaluating = mode; + }); + + // Interrupt handling is largely handled within the domain but when taking user input we need to handle it + repl.mdomain.interrupted.on(() => { + if (evaluating || (!repl.inMultilineCommand && repl.line === "")) { + return; + } + + repl.inMultilineCommand = false; + repl.clearBufferedCommand(); + (repl as any).line = ""; + + repl.updatingPrompt = true; + stdout.write("\n"); + repl.displayPrompt(); + repl.updatingPrompt = false; + + return false; + }); +} + +/** + * If we just use readline's "pause" then it eats control-c so we can't interrupt. + * + * So instead just buffer events ourselves during evaluation. This installs after the ctrl-c handler + */ +function installEvaluationInputBuffer(repl: AugmentedRepl) { + let buffering = false; + const buffer = Array(); + + repl.evaluationModeChange.on(mode => { + if (mode) { + buffering = true; + } else { + for (let event = buffer.shift(); event; event = buffer.shift()) { + repl.deliverKeypress(event); + } + buffering = false; + } }); - const historyPath = env.MATTER_REPL_HISTORY || join(homedir(), ".matter-cli-history"); - server.setupHistory(historyPath, error => { + repl.keypressReceived.on(event => { + if (!buffering) { + return; + } + + buffer.push(event); + return false; + }); +} + +/** + * Configure history support. + */ +function configureHistory(repl: AugmentedRepl) { + let historyPath = repl.mdomain.env.vars.string("history.path"); + if (historyPath === undefined) { + const storagePath = repl.mdomain.env.get(StorageService).location; + if (storagePath === undefined) { + historyPath = join(homedir(), ".matter-cli-history"); + } else { + historyPath = join(storagePath, "cli-history"); + } + } + + repl.setupHistory(historyPath, error => { if (error) { console.error(error); exit(1); } }); +} - const realEmit = server.emit as (...args: unknown[]) => boolean; - server.emit = (event, ...args: any[]) => { - if (event === "line") { - args[0] = `${LINE_PROTECTOR_CHAR}${args[0]}`; - } - return realEmit.call(server, event, ...args); - }; +/** + * Add completion for commands, paths and expressions. + * + * TODO - this completion is not yet all that complete + */ +function addCompletionSupport(repl: AugmentedRepl) { + const nodeCompleter = repl.completer; const complete: AsyncCompleter = (line, callback) => { findCompletions(line).then(result => { if (result) { callback(null, result); } else { - nodeCompleter.call(server, line, callback); + (nodeCompleter as any) /* TS bug */ + .call(repl, line, callback); } }, callback); }; - const nodeCompleter = server.completer; - Object.defineProperty(server, "completer", { value: complete }); - - function createPrompt() { - return `${colors.dim("matter")} ${colors.yellow(domain.location.path)} ❯ `; - } + Object.defineProperty(repl, "completer", { value: complete, configurable: true, writable: true }); async function findCompletions(line: string): Promise { if (line.endsWith("/") ? !isCommand(line.slice(0, line.length - 1)) : !isCommand(line)) { @@ -195,7 +424,7 @@ export async function repl() { const completions = Array(); for (const path of pathsToSearch) { - const location = await domain.location.maybeAt(path); + const location = await repl.mdomain.location.maybeAt(path); if (location?.kind !== "directory") { continue; } @@ -207,23 +436,127 @@ export async function repl() { } } -async function readPackageVersion() { - let path = new URL(import.meta.url).pathname; - while (dirname(path) !== path) { - path = dirname(path); - try { - const pkg = await readFile(join(path, "package.json")); - const parsed = JSON.parse(pkg.toString()); - if (typeof parsed.version === "string") { - return parsed.version; +/** + * Hook output streams so we can ensure output doesn't overwrite the prompt. + */ +function instrumentReplToMaintainPrompt(repl: AugmentedRepl) { + if (!stdout.isTTY) { + return; + } + + let evaluating = false; + let promptHidden = false; + let emitting = false; + + instrumentStdStream(stdout); + instrumentStdStream(stderr); + + repl.keypressDelivering.on(() => { + repl.updatingPrompt = true; + restorePrompt(); + }); + + repl.keypressDelivered.on(() => { + repl.updatingPrompt = false; + }); + + repl.evaluationModeChange.on(mode => { + evaluating = mode; + }); + + function instrumentStdStream(stream: NodeJS.WriteStream) { + const actualWrite = stream.write.bind(stream); + stream.write = (payload: Uint8Array | string, ...params: any[]) => { + // Doesn't catch cursor movement from ANSI codes but worse case we end up with a blank line + repl.onNewline = payload[payload.length - 1] === "\n" || payload[payload.length - 1] === "\r"; + + if (!evaluating && !promptHidden && !repl.updatingPrompt && !emitting) { + promptHidden = true; + stdout.cursorTo(0); + stdout.clearLine(0); + queueMicrotask(restorePrompt); + repl.onNewline = true; } - } catch (e) { - if ((e as any).code === "ENOENT") { - continue; + + if (!emitting) { + emitting = true; + try { + repl.outputDisplayed.emit(); + } finally { + emitting = false; + } } - throw e; + + return actualWrite(payload, ...params); + }; + } + + function restorePrompt() { + if (!promptHidden) { + return; } + + if (!repl.onNewline) { + stdout.write("\n"); + } + + repl?.displayPrompt(true); + promptHidden = false; } +} - return "?"; +/** + * Inject our janky little line prefix that prevents node from processing "dot" commands. + */ +function instrumentReplToAddLineProtector(repl: REPLServer) { + const realEmit = repl.emit as (...args: unknown[]) => boolean; + repl.emit = (event, ...args: any[]) => { + if (event === "line") { + args[0] = `${LINE_PROTECTOR_CHAR}${args[0]}`; + } + return realEmit.call(repl, event, ...args); + }; +} + +/** + * Just wouldn't be complete without it. + */ +function configureSpinner(repl: AugmentedRepl) { + let spinnerVisible = false; + let spinnerPhase = 0; + const spinner = Time.getPeriodicTimer("cli-spinner", SPINNER_INTERVAL, spin); + + function spin() { + if (!repl.onNewline) { + repl.mdomain.out("\n"); + } + if (spinnerVisible) { + stdout.cursorTo(0); + stdout.clearLine(0); + } + stdout.write(colors.yellow(" " + SPINNER[spinnerPhase % SPINNER.length])); + stdout.cursorTo(0); + repl.onNewline = true; + spinnerPhase++; + spinnerVisible = true; + } + + repl.evaluationModeChange.on(mode => { + if (mode) { + spinner.start(); + } else { + spinner.stop(); + spinnerVisible = false; + } + }); + + repl.outputDisplayed.on(() => { + if (!spinnerVisible) { + return; + } + + const onNewline = repl.onNewline; + stdout.clearLine(1); + repl.onNewline = onNewline; + }); } diff --git a/packages/cli-tool/src/stat.ts b/packages/cli-tool/src/stat.ts index 3192231fe8..9501caacbd 100644 --- a/packages/cli-tool/src/stat.ts +++ b/packages/cli-tool/src/stat.ts @@ -120,7 +120,8 @@ export namespace Stat { definition !== null && !Array.isArray(definition) && !ArrayBuffer.isView(definition) && - !(definition instanceof Date) + !(definition instanceof Date) && + !(definition.constructor.name === "BasicObservable") ); } } diff --git a/packages/cli-tool/src/tsconfig.json b/packages/cli-tool/src/tsconfig.json index dfeb7fd69e..6f45176751 100644 --- a/packages/cli-tool/src/tsconfig.json +++ b/packages/cli-tool/src/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../node/src" }, + { + "path": "../../nodejs/src" + }, { "path": "../../protocol/src" }, diff --git a/packages/general/src/environment/RuntimeService.ts b/packages/general/src/environment/RuntimeService.ts index 102dec074d..23ec393bbb 100644 --- a/packages/general/src/environment/RuntimeService.ts +++ b/packages/general/src/environment/RuntimeService.ts @@ -158,6 +158,19 @@ export class RuntimeService implements Multiplex { } } + /** + * Interrupt handler. Triggered by e.g. on SIGINT on unixish systems. + * + * The default implementation cancels the runtime. + * + * @returns a boolean indicating whether to continue trapping interrupts + */ + interrupt(): boolean { + this.cancel(); + + return false; + } + /** * Resolves when no workers are active. */ diff --git a/packages/general/src/environment/VariableService.ts b/packages/general/src/environment/VariableService.ts index 2c3742c715..1ab1f2620f 100644 --- a/packages/general/src/environment/VariableService.ts +++ b/packages/general/src/environment/VariableService.ts @@ -126,6 +126,13 @@ export class VariableService { } } + async persist(name: string, value: VariableService.Value) { + this.set(name, value); + await this.persistConfigValue?.(name, value); + } + + persistConfigValue?: (name: string, value: VariableService.Value) => Promise; + string(name: string) { const value = this.get(name); if (value === undefined) { diff --git a/packages/general/src/log/Diagnostic.ts b/packages/general/src/log/Diagnostic.ts index 5ecf5c9e4f..24edf4b3d1 100644 --- a/packages/general/src/log/Diagnostic.ts +++ b/packages/general/src/log/Diagnostic.ts @@ -339,6 +339,9 @@ function messageAndStackFor(error: any, parentStack?: string[]) { let message: string | undefined; let rawStack: string | undefined; if (error !== undefined && error !== null) { + if (typeof error === "string" || typeof error === "number") { + return { message: `${error}` }; + } if ("message" in error) { ({ message, stack: rawStack } = error); } else if (error.message) { diff --git a/packages/general/src/net/ServerAddress.ts b/packages/general/src/net/ServerAddress.ts index 1f6fb3dd11..31d06879d9 100644 --- a/packages/general/src/net/ServerAddress.ts +++ b/packages/general/src/net/ServerAddress.ts @@ -15,7 +15,15 @@ export type ServerAddressBle = { peripheralAddress: string; }; -export type ServerAddress = ServerAddressIp | ServerAddressBle; +export interface Lifespan { + /** Beginning of lifespan (system time in milliseconds) */ + discoveredAt: number; + + /** Length of lifespan, if known (milliseconds) */ + ttl: number; +} + +export type ServerAddress = (ServerAddressIp | ServerAddressBle) & Partial; export function serverAddressToString(address: ServerAddress): string { return address.type === "udp" ? `udp://${address.ip}:${address.port}` : `ble://${address.peripheralAddress}`; diff --git a/packages/general/src/time/Time.ts b/packages/general/src/time/Time.ts index 3adcaa103c..7119ac9e24 100644 --- a/packages/general/src/time/Time.ts +++ b/packages/general/src/time/Time.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { CancelablePromise } from "#util/Promises.js"; import { ImplementationError } from "../MatterError.js"; import { Diagnostic } from "../log/Diagnostic.js"; import { DiagnosticSource } from "../log/DiagnosticSource.js"; @@ -29,22 +30,46 @@ export class Time { } static readonly nowMs = (): number => Time.get().nowMs(); - /** Returns a timer that will call callback after durationMs has passed. */ + /** + * Create a timer that will call callback after durationMs has passed. + */ getTimer(name: string, durationMs: number, callback: Timer.Callback): Timer { return new StandardTimer(name, durationMs, callback, false); } static readonly getTimer = (name: string, durationMs: number, callback: Timer.Callback): Timer => Time.get().getTimer(name, durationMs, callback); - /** Returns a timer that will periodically call callback at intervalMs intervals. */ + /** + * Create a timer that will periodically call callback at intervalMs intervals. + */ getPeriodicTimer(name: string, intervalMs: number, callback: Timer.Callback): Timer { return new StandardTimer(name, intervalMs, callback, true); } static readonly getPeriodicTimer = (name: string, intervalMs: number, callback: Timer.Callback): Timer => Time.get().getPeriodicTimer(name, intervalMs, callback); - static readonly sleep = async (name: string, durationMs: number): Promise => - new Promise(resolve => Time.get().getTimer(name, durationMs, resolve).start()); + /** + * Create a promise that resolves after a specific interval or when canceled, whichever comes first. + */ + sleep(name: string, durationMs: number): CancelablePromise { + let timer: Timer; + let resolver: () => void; + return new CancelablePromise( + resolve => { + resolver = resolve; + timer = Time.getTimer(name, durationMs, resolve); + timer.start(); + }, + + () => { + timer.stop(); + resolver(); + }, + ); + } + static sleep(name: string, durationMs: number) { + return Time.get().sleep(name, durationMs); + } static register(timer: Timer) { timer.elapsed = Diagnostic.elapsed(); diff --git a/packages/general/src/util/Promises.ts b/packages/general/src/util/Promises.ts index c118199fb2..632625e646 100644 --- a/packages/general/src/util/Promises.ts +++ b/packages/general/src/util/Promises.ts @@ -281,3 +281,55 @@ export const MaybePromise = { }; MaybePromise.toString = () => "MaybePromise"; + +/** + * A "promise" that may be canceled. + * + * Behaviors like a normal promise but does not actually extend {@link Promise} because that makes extension a PITA. + */ +export class CancelablePromise implements Promise { + #promise: Promise; + + constructor( + executor: (resolve: (value: T | PromiseLike) => void, reject: (reason?: any) => void) => void, + onCancel?: () => void, + ) { + this.#promise = new Promise(executor); + if (onCancel !== undefined) { + this.cancel = onCancel; + } + } + + cancel() {} + + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, + ): CancelablePromise { + const result = this.#promise.then(onfulfilled, onrejected) as CancelablePromise; + result.cancel = this.cancel.bind(this); + return result; + } + + catch( + onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null, + ): CancelablePromise { + return this.then(onrejected); + } + + finally(onfinally?: (() => void) | undefined | null): CancelablePromise { + const handler = (result: any) => { + onfinally?.(); + return result; + }; + return this.then(handler, handler); + } + + get [Symbol.toStringTag]() { + return this.#promise[Symbol.toStringTag]; + } + + static is(value: MaybePromise): value is CancelablePromise { + return MaybePromise.is(value) && typeof (value as CancelablePromise).cancel === "function"; + } +} diff --git a/packages/matter.js/src/CommissioningController.ts b/packages/matter.js/src/CommissioningController.ts index 02f165a88b..2f262bf01b 100644 --- a/packages/matter.js/src/CommissioningController.ts +++ b/packages/matter.js/src/CommissioningController.ts @@ -24,13 +24,13 @@ import { CommissionableDeviceIdentifiers, ControllerDiscovery, DecodedAttributeReportValue, + DiscoveryAndCommissioningOptions, DiscoveryData, InteractionClient, MdnsBroadcaster, MdnsScanner, MdnsService, NodeDiscoveryType, - PeerCommissioningOptions, ScannerSet, } from "#protocol"; import { @@ -117,8 +117,8 @@ export type CommissioningControllerOptions = CommissioningControllerNodeOptions /** Options needed to commission a new node */ export type NodeCommissioningOptions = CommissioningControllerNodeOptions & { - commissioning: Omit; - discovery: PeerCommissioningOptions["discovery"]; + commissioning: Omit; + discovery: DiscoveryAndCommissioningOptions["discovery"]; passcode: number; }; diff --git a/packages/matter.js/src/CommissioningServer.ts b/packages/matter.js/src/CommissioningServer.ts index d06657f62f..1774d4aa73 100644 --- a/packages/matter.js/src/CommissioningServer.ts +++ b/packages/matter.js/src/CommissioningServer.ts @@ -549,6 +549,7 @@ export class CommissioningServer extends MatterNode { this.storage.createContext("SessionManager"), this.storage.createContext("FabricManager"), () => ({ + enabled: true, productDescription: this.productDescription, passcode: this.passcode, discriminator: this.discriminator, diff --git a/packages/matter.js/src/MatterController.ts b/packages/matter.js/src/MatterController.ts index 935520e571..8266ef6865 100644 --- a/packages/matter.js/src/MatterController.ts +++ b/packages/matter.js/src/MatterController.ts @@ -39,6 +39,7 @@ import { DEFAULT_ADMIN_VENDOR_ID, DEFAULT_FABRIC_ID, DeviceAdvertiser, + DiscoveryAndCommissioningOptions, DiscoveryData, DiscoveryOptions, ExchangeManager, @@ -50,7 +51,6 @@ import { OperationalPeer, PeerAddress, PeerAddressStore, - PeerCommissioningOptions, PeerSet, ResumptionRecord, RetransmissionLimitReachedError, @@ -349,7 +349,7 @@ export class MatterController { options: NodeCommissioningOptions, completeCommissioningCallback?: (peerNodeId: NodeId, discoveryData?: DiscoveryData) => Promise, ): Promise { - const commissioningOptions: PeerCommissioningOptions = { + const commissioningOptions: DiscoveryAndCommissioningOptions = { ...options.commissioning, fabric: this.fabric, discovery: options.discovery, @@ -357,7 +357,7 @@ export class MatterController { }; if (completeCommissioningCallback) { - commissioningOptions.performCaseCommissioning = async (peerAddress, discoveryData) => { + commissioningOptions.finalizeCommissioning = async (peerAddress, discoveryData) => { const result = await completeCommissioningCallback(peerAddress.nodeId, discoveryData); if (!result) { throw new RetransmissionLimitReachedError("Device could not be discovered"); @@ -365,7 +365,7 @@ export class MatterController { }; } - const address = await this.commissioner.commission(commissioningOptions); + const address = await this.commissioner.commissionWithDiscovery(commissioningOptions); await this.#store.fabricStorage.set("fabric", this.fabric.config); diff --git a/packages/matter.js/src/MatterDevice.ts b/packages/matter.js/src/MatterDevice.ts index bfc4913b95..fa987032aa 100644 --- a/packages/matter.js/src/MatterDevice.ts +++ b/packages/matter.js/src/MatterDevice.ts @@ -269,11 +269,11 @@ export class MatterDevice { } getFabrics() { - return this.#fabricManager.getFabrics(); + return this.#fabricManager.fabrics; } isCommissioned() { - return !!this.#fabricManager.getFabrics().length; + return !!this.#fabricManager.length; } async allowEnhancedCommissioning( diff --git a/packages/matter.js/src/compat/behavior.ts b/packages/matter.js/src/compat/behavior.ts index 9e4447f97c..15bb3ff38b 100644 --- a/packages/matter.js/src/compat/behavior.ts +++ b/packages/matter.js/src/compat/behavior.ts @@ -8,7 +8,7 @@ export { AccessControl, Behavior, ClusterBehavior, - CommissioningBehavior, + CommissioningServer as CommissioningBehavior, NetworkBehavior, PartsBehavior, ProductDescriptionServer, diff --git a/packages/node/src/behavior/internal/ClientBehaviorBacking.ts b/packages/node/src/behavior/internal/ClientBehaviorBacking.ts index 73b241f4e2..663c4388d7 100644 --- a/packages/node/src/behavior/internal/ClientBehaviorBacking.ts +++ b/packages/node/src/behavior/internal/ClientBehaviorBacking.ts @@ -16,8 +16,8 @@ import { BehaviorBacking } from "./BehaviorBacking.js"; export class ClientBehaviorBacking extends BehaviorBacking { protected override store: Datasource.Store | undefined; - constructor(endpoint: Endpoint, behavior: Behavior.Type, endpointStore: EndpointStore) { - super(endpoint, behavior); + constructor(endpoint: Endpoint, behavior: Behavior.Type, endpointStore: EndpointStore, options?: Behavior.Options) { + super(endpoint, behavior, options); this.store = endpointStore.storeForBehavior(behavior.id); } diff --git a/packages/node/src/behavior/internal/ClusterServerBacking.ts b/packages/node/src/behavior/internal/ClusterServerBacking.ts index ef986e195b..13ad79dd8b 100644 --- a/packages/node/src/behavior/internal/ClusterServerBacking.ts +++ b/packages/node/src/behavior/internal/ClusterServerBacking.ts @@ -151,7 +151,7 @@ export class ClusterServerBacking extends ServerBehaviorBacking { }, get fabrics() { - return env.get(FabricManager).getFabrics(); + return env.get(FabricManager).fabrics; }, // We handle change management ourselves diff --git a/packages/node/src/behavior/system/commissioning/CommissioningClient.ts b/packages/node/src/behavior/system/commissioning/CommissioningClient.ts new file mode 100644 index 0000000000..0381a534e2 --- /dev/null +++ b/packages/node/src/behavior/system/commissioning/CommissioningClient.ts @@ -0,0 +1,328 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Behavior } from "#behavior/Behavior.js"; +import { ImplementationError, NotImplementedError, ServerAddress, Time } from "#general"; +import { DatatypeModel, FieldElement } from "#model"; +import type { ClientNode } from "#node/ClientNode.js"; +import { Node } from "#node/Node.js"; +import { + CommissioningMode, + ControllerCommissioner, + DiscoveryData, + Fabric, + FabricAuthority, + FabricManager, + LocatedNodeCommissioningOptions, + PeerAddress, + SessionParameters, +} from "#protocol"; +import { DeviceTypeId, DiscoveryCapabilitiesBitmap, NodeId, TypeFromPartialBitSchema, VendorId } from "#types"; +import { ControllerBehavior } from "../controller/ControllerBehavior.js"; +import { RemoteDescriptor } from "./RemoteDescriptor.js"; + +/** + * Client functionality related to commissioning. + * + * Updates node state based on commissioning status and commissions new nodes. + */ +export class CommissioningClient extends Behavior { + declare state: CommissioningClient.State; + + static override readonly early = true; + + static override readonly id = "commissioning"; + override initialize(options: { descriptor?: RemoteDescriptor }) { + const descriptor = options?.descriptor; + if (descriptor) { + this.descriptor = descriptor; + } + + if (this.state.discoveredAt === undefined) { + this.state.discoveredAt = Time.nowMs(); + } + + this.reactTo((this.endpoint as Node).lifecycle.partsReady, this.#initializeNode); + } + + commission(passcode: number): Promise; + + commission(options: CommissioningClient.CommissioningOptions): Promise; + + async commission(options: number | CommissioningClient.CommissioningOptions) { + if (typeof options !== "object") { + options = { passcode: options }; + } + + // Validate passcode + let { passcode } = options; + if (typeof passcode !== "number" || Number.isNaN(passcode)) { + passcode = Number.parseInt(passcode as unknown as string); + if (Number.isNaN(passcode)) { + throw new ImplementationError(`You must provide the numeric passcode to commission a node`); + } + } + + // Commissioning can only happen once + const node = this.endpoint as ClientNode; + if (this.state.peerAddress !== undefined) { + throw new ImplementationError(`${node} is already commissioned`); + } + + // Ensure controller is initialized + await this.endpoint.owner?.act(agent => agent.load(ControllerBehavior)); + + // Obtain the fabric we will commission into + const fabricAuthority = options.fabricAuthority || this.env.get(FabricAuthority); + let { fabric } = options; + if (fabric === undefined) { + if (this.context.fabric === undefined) { + fabric = await fabricAuthority.defaultFabric(); + } else { + fabric = node.env.get(FabricManager).for(this.context.fabric); + } + } + + if (!fabricAuthority.hasControlOf(fabric)) { + throw new ImplementationError( + `Cannot commission ${node} fabric ${fabric.fabricIndex} because we do not control this fabric`, + ); + } + + const addresses = this.state.addresses; + if (!addresses?.length) { + throw new ImplementationError(`Cannot commission ${node} because the node has not been located`); + } + + const commissioner = this.endpoint.env.get(ControllerCommissioner); + + const commissioningOptions: LocatedNodeCommissioningOptions = { + addresses, + fabric, + nodeId: options.nodeId, + passcode, + discoveryData: this.descriptor, + }; + + if (this.finalizeCommissioning !== CommissioningClient.prototype.finalizeCommissioning) { + commissioningOptions.finalizeCommissioning = this.finalizeCommissioning.bind(this); + } + + const address = await commissioner.commission(commissioningOptions); + this.state.peerAddress = address; + + return node; + } + + /** + * Override to implement CASE commissioning yourself. + * + * If you override, matter.js commissions to the point where over PASE is complete. You must then complete + * commissioning yourself by connecting to the device and invokeint the "CommissioningComplete" command. + */ + protected async finalizeCommissioning(_address: PeerAddress, _discoveryData?: DiscoveryData) { + throw new NotImplementedError(); + } + + get descriptor() { + return RemoteDescriptor.fromLongForm(this.state); + } + + set descriptor(descriptor: RemoteDescriptor | undefined) { + RemoteDescriptor.toLongForm(descriptor, this.state); + } + + #initializeNode() { + const endpoint = this.endpoint as ClientNode; + endpoint.lifecycle.initialized.emit(this.state.peerAddress !== undefined); + } + + /** + * Define logical schema. This enables runtime validation, make fields persistent and makes subfields editable. + */ + static override readonly schema = new DatatypeModel({ + name: "CommissioningState", + type: "struct", + + children: [ + FieldElement({ + name: "peerAddress", + type: "struct", + quality: "N", + children: [ + FieldElement({ name: "fabricIndex", type: "fabric-id" }), + FieldElement({ name: "nodeId", type: "node-id" }), + ], + }), + FieldElement({ + name: "addresses", + type: "list", + quality: "N", + children: [ + FieldElement({ + name: "entry", + type: "struct", + children: [ + FieldElement({ name: "type", type: "string" }), + FieldElement({ name: "ip", type: "string" }), + FieldElement({ name: "port", type: "uint16" }), + FieldElement({ name: "peripheralAddress", type: "string" }), + ], + }), + ], + }), + FieldElement({ name: "discoveredAt", type: "systime-ms", quality: "N", conformance: "M" }), + FieldElement({ name: "ttl", type: "number", quality: "N" }), + FieldElement({ name: "deviceIdentifier", type: "string", quality: "N" }), + FieldElement({ name: "discriminator", type: "uint16", quality: "N" }), + FieldElement({ name: "commissioningMode", type: "uint8", quality: "N" }), + FieldElement({ name: "vendorId", type: "vendor-id", quality: "N" }), + FieldElement({ name: "productId", type: "uint16", quality: "N" }), + FieldElement({ name: "deviceType", type: "uint16", quality: "N" }), + FieldElement({ name: "deviceName", type: "string", quality: "N" }), + FieldElement({ name: "rotatingIdentifier", type: "string", quality: "N" }), + FieldElement({ name: "pairingHint", type: "uint32", quality: "N" }), + FieldElement({ name: "pairingInstructions", type: "string", quality: "N" }), + FieldElement({ + name: "sessionParameters", + type: "struct", + quality: "N", + children: [ + FieldElement({ name: "idleIntervalMs", type: "uint32", constraint: "max 3600000" }), + FieldElement({ name: "activeIntervalMs", type: "uint32", constraint: "max 3600000" }), + FieldElement({ name: "activeThresholdMs", type: "uint16" }), + ], + }), + FieldElement({ name: "tcpSupport", type: "uint8", quality: "N" }), + FieldElement({ name: "longIdleOperatingMode", type: "bool", quality: "N" }), + ], + }); +} + +export namespace CommissioningClient { + export class State { + /** + * Fabric index and node ID for paired peers. If this is undefined the node is uncommissioned. + */ + peerAddress?: PeerAddress; + + /** + * Known network addresses for the device. If this is undefined the node has not been located on any network + * interface. + * + * TODO - track discovery time and TTL on individual addresses + */ + addresses?: ServerAddress[]; + + /** + * Time at which the device was discovered. + */ + discoveredAt: number = Time.nowMs(); + + /** + * The TTL of the discovery record if applicable. + */ + ttl?: number; + + /** + * The canonical global ID of the device. + */ + deviceIdentifier?: string; + + /** + * The device's long discriminator. + */ + discriminator?: number; + + /** + * The last know commissioning mode of the device. + */ + commissioningMode?: CommissioningMode; + + /** + * Vendor. + */ + vendorId?: VendorId; + + /** + * Product. + */ + productId?: number; + + /** + * Advertised device type. + */ + deviceType?: DeviceTypeId; + + /** + * The advertised device name specified by the user. + */ + deviceName?: string; + + /** + * An optional manufacturer-specific unique rotating ID for uniquely identifying the device. + */ + rotatingIdentifier?: string; + + /** + * A bitmap indicating how to transition the device to commissioning mode from its current state. + */ + pairingHint?: number; + + /** + * Textual pairing instructions associated with pairing hint. + */ + pairingInstructions?: string; + + /** + * The remote node's session parameters. + */ + sessionParameters?: Partial; + + /** + * TCP support bitmap. + */ + tcpSupport?: number; + + /** + * Indicates whether node is ICD with a slow (15 s+) polling interval. + */ + longIdleTimeOperatingMode?: boolean; + } + + /** + * Options that control commissioning. + */ + export interface CommissioningOptions { + /** + * The device's passcode. + */ + passcode: number; + + /** + * The ID to assign the node during commissioning. By default the node receives the next available ID. + */ + nodeId?: NodeId; + + /** + * The fabric the joins upon commissioning. Defaults to the default fabric of the assigned + * {@link FabricAuthority}. + */ + fabric?: Fabric; + + /** + * The authority controlling the commissioning fabric. Defaults to the {@link FabricAuthority} of the local + * environment. + */ + fabricAuthority?: FabricAuthority; + + /** + * Discovery capabilities to use for discovery. These are included in the QR code normally and defined if BLE + * is supported for initial commissioning. + */ + discoveryCapabilities?: TypeFromPartialBitSchema; + } +} diff --git a/packages/node/src/behavior/system/commissioning/CommissioningBehavior.ts b/packages/node/src/behavior/system/commissioning/CommissioningServer.ts similarity index 89% rename from packages/node/src/behavior/system/commissioning/CommissioningBehavior.ts rename to packages/node/src/behavior/system/commissioning/CommissioningServer.ts index 599e488977..2fd71d60ac 100644 --- a/packages/node/src/behavior/system/commissioning/CommissioningBehavior.ts +++ b/packages/node/src/behavior/system/commissioning/CommissioningServer.ts @@ -5,8 +5,8 @@ */ import { Endpoint } from "#endpoint/Endpoint.js"; -import type { EndpointServer } from "#endpoint/EndpointServer.js"; import { + AsyncObservable, Diagnostic, EventEmitter, ImplementationError, @@ -40,16 +40,16 @@ import { SessionsBehavior } from "../sessions/SessionsBehavior.js"; const logger = Logger.get("Commissioning"); /** - * Server functionality related to commissioning used by {@link EndpointServer}. + * Server behavior related to commissioning. * - * Better name would be CommissioningServer but we already have one of those. + * Updates node state based on commissioning status. */ -export class CommissioningBehavior extends Behavior { +export class CommissioningServer extends Behavior { static override readonly id = "commissioning"; - declare state: CommissioningBehavior.State; - declare events: CommissioningBehavior.Events; - declare internal: CommissioningBehavior.Internal; + declare state: CommissioningServer.State; + declare events: CommissioningServer.Events; + declare internal: CommissioningServer.Internal; static override early = true; @@ -98,7 +98,7 @@ export class CommissioningBehavior extends Behavior { } } - const fabrics = this.env.get(FabricManager).getFabrics(); + const fabrics = this.env.get(FabricManager); const commissioned = !!fabrics.length; if (fabricAction === FabricAction.Removed) { delete this.state.fabrics[fabricIndex]; @@ -151,9 +151,7 @@ export class CommissioningBehavior extends Behavior { } #triggerFactoryReset() { - this.env.runtime.add( - (this.endpoint as ServerNode).factoryReset().then(this.callback(this.initiateCommissioning)), - ); + this.env.runtime.add((this.endpoint as ServerNode).erase().then(this.callback(this.initiateCommissioning))); } #monitorFailsafe(failsafe: FailsafeContext) { @@ -162,7 +160,7 @@ export class CommissioningBehavior extends Behavior { } // Callback that listens to the failsafe for destruction and triggers commissioning status update - const listener = this.callback(function (this: CommissioningBehavior, status: Lifecycle.Status) { + const listener = this.callback(function (this: CommissioningServer, status: Lifecycle.Status) { if (status === Lifecycle.Status.Destroyed) { if (failsafe.fabricIndex !== undefined) { this.handleFabricChange( @@ -175,7 +173,7 @@ export class CommissioningBehavior extends Behavior { }); // Callback that removes above listener - this.internal.unregisterFailsafeListener = this.callback(function (this: CommissioningBehavior) { + this.internal.unregisterFailsafeListener = this.callback(function (this: CommissioningServer) { failsafe.construction.change.off(listener); this.internal.unregisterFailsafeListener = undefined; }); @@ -185,7 +183,8 @@ export class CommissioningBehavior extends Behavior { } /** - * The server invokes this method when the node is active but not yet commissioned. + * The server invokes this method when the node is active but not yet commissioned unless you set + * {@link CommissioningServer.State#enabled} to false. * * An uncommissioned node is not yet associated with fabrics. It cannot be used until commissioned by a controller. * @@ -216,7 +215,7 @@ export class CommissioningBehavior extends Behavior { */ static pairingCodesFor(node: Endpoint) { const bi = node.stateOf(BasicInformationBehavior); - const comm = node.stateOf(CommissioningBehavior); + const comm = node.stateOf(CommissioningServer); const net = node.stateOf(NetworkServer); const qrPairingCode = QrPairingCodeCodec.encode([ @@ -254,9 +253,11 @@ export class CommissioningBehavior extends Behavior { }); #nodeOnline() { - const fabrics = this.env.get(FabricManager).getFabrics(); + const fabrics = this.env.get(FabricManager).fabrics; if (!fabrics.length) { - this.initiateCommissioning(); + if (this.state.enabled) { + this.initiateCommissioning(); + } } else { const exposedFabrics: Record = {}; fabrics.forEach( @@ -272,7 +273,7 @@ export class CommissioningBehavior extends Behavior { } } -export namespace CommissioningBehavior { +export namespace CommissioningServer { export interface PairingCodes { manualPairingCode: string; qrPairingCode: string; @@ -282,7 +283,8 @@ export namespace CommissioningBehavior { unregisterFailsafeListener?: () => void = undefined; } - export class State implements CommissioningOptions { + export class State { + enabled?: boolean; commissioned = false; fabrics: Record = {}; passcode = -1; @@ -294,15 +296,16 @@ export namespace CommissioningBehavior { [Val.properties](endpoint: Endpoint) { return { get pairingCodes() { - return CommissioningBehavior.pairingCodesFor(endpoint); + return CommissioningServer.pairingCodesFor(endpoint); }, }; } } export class Events extends EventEmitter { - commissioned = Observable<[session: ActionContext]>(); - decommissioned = Observable<[session: ActionContext]>(); + commissioned = Observable<[context: ActionContext]>(); + decommissioned = Observable<[context: ActionContext]>(); fabricsChanged = Observable<[fabricIndex: FabricIndex, action: FabricAction]>(); + enabled$Changed = AsyncObservable<[context: ActionContext]>(); } } diff --git a/packages/node/src/behavior/system/commissioning/RemoteDescriptor.ts b/packages/node/src/behavior/system/commissioning/RemoteDescriptor.ts new file mode 100644 index 0000000000..f240641979 --- /dev/null +++ b/packages/node/src/behavior/system/commissioning/RemoteDescriptor.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Immutable } from "#general"; +import { CommissionableDevice, OperationalDevice, PeerAddress, SessionParameters } from "#protocol"; +import { DeviceTypeId, VendorId } from "#types"; +import type { CommissioningClient } from "./CommissioningClient.js"; + +/** + * Device descriptor used by lower-level components. + */ +export type RemoteDescriptor = Partial; + +export namespace RemoteDescriptor { + /** + * The "long form" descriptor used by higher-level components. + */ + export type Long = CommissioningClient.State; + + /** + * The subset of device identifiers that matches canonically for identity purposes. + */ + export interface Identifier { + readonly peerAddress?: Readonly; + readonly deviceIdentifier?: string; + } + + export function is(subject: Identifier, object: Identifier) { + if (object.peerAddress !== undefined && subject.peerAddress !== undefined) { + return PeerAddress.is(subject.peerAddress, object.peerAddress); + } + + if (object.deviceIdentifier !== undefined) { + return subject.deviceIdentifier === object.deviceIdentifier; + } + + return false; + } + + export function fromLongForm(long: Immutable): RemoteDescriptor { + const result: RemoteDescriptor = {}; + + const { + addresses, + discoveredAt, + ttl, + deviceIdentifier, + discriminator, + commissioningMode, + vendorId, + productId, + deviceType, + deviceName, + rotatingIdentifier, + pairingHint, + pairingInstructions, + sessionParameters, + tcpSupport, + longIdleTimeOperatingMode, + } = long; + + if (discoveredAt !== undefined) { + result.discoveredAt = discoveredAt; + } + + if (ttl !== undefined) { + result.ttl = ttl; + } + + if (deviceIdentifier !== undefined) { + result.deviceIdentifier = deviceIdentifier; + } + + if (vendorId !== undefined) { + if (productId !== undefined) { + result.VP = `${vendorId}+${productId}`; + } else { + result.VP = `${vendorId}`; + } + } + + if (deviceType !== undefined) { + result.DT = deviceType; + } + + if (deviceName !== undefined) { + result.DN = deviceName; + } + + if (rotatingIdentifier !== undefined) { + result.RI = rotatingIdentifier; + } + + if (pairingHint !== undefined) { + result.PH = pairingHint; + } + + if (pairingInstructions !== undefined) { + result.PI = pairingInstructions; + } + + if (sessionParameters !== undefined) { + const { idleIntervalMs, activeIntervalMs, activeThresholdMs } = sessionParameters; + + if (idleIntervalMs !== undefined) { + result.SII = idleIntervalMs; + } + + if (activeIntervalMs !== undefined) { + result.SAI = activeIntervalMs; + } + + if (activeThresholdMs !== undefined) { + result.SAT = activeThresholdMs; + } + } + + if (tcpSupport !== undefined) { + result.T = tcpSupport; + } + + if (longIdleTimeOperatingMode !== undefined) { + result.ICD = 1; + } + + const isOperational = long.peerAddress !== undefined; + if (isOperational) { + if (addresses !== undefined) { + result.addresses = addresses?.filter(address => address.type === "udp"); + } + } else { + if (addresses !== undefined) { + result.addresses = addresses.map(address => ({ ...address })); + } + + if (discriminator !== undefined) { + (result as CommissionableDevice).D = discriminator; + } + + if (commissioningMode !== undefined) { + (result as CommissionableDevice).CM = commissioningMode; + } + } + + return result; + } + + export function toLongForm(descriptor: RemoteDescriptor | undefined, long: Long) { + if (!descriptor) { + descriptor = {}; + } + + const { addresses, discoveredAt, ttl, deviceIdentifier, VP, DT, DN, RI, PH, PI, SII, SAI, SAT, T, ICD } = + descriptor; + + if (discoveredAt !== undefined) { + long.discoveredAt = discoveredAt; + } + + if (ttl !== undefined) { + long.ttl = ttl; + } + + if (addresses?.length) { + long.addresses = addresses; + } + + if (deviceIdentifier !== undefined) { + long.deviceIdentifier = deviceIdentifier; + } + + if (VP !== undefined) { + const [vendor, product] = VP.split("+").map(Number.parseInt); + + long.vendorId = Number.isNaN(vendor) ? undefined : VendorId(vendor); + long.productId = Number.isNaN(product) ? undefined : VendorId(vendor); + } + + let sessionParameters: Partial | undefined; + if (SII !== undefined) { + (sessionParameters ??= {}).idleIntervalMs = SII; + } + if (SAI !== undefined) { + (sessionParameters ??= {}).activeIntervalMs = SAI; + } + if (SAT !== undefined) { + (sessionParameters ??= {}).activeThresholdMs = SAT; + } + long.sessionParameters = sessionParameters; + + long.deviceType = DT === undefined ? undefined : DeviceTypeId(DT); + long.deviceName = DN; + long.rotatingIdentifier = RI; + long.pairingHint = PH; + long.pairingInstructions = PI; + long.tcpSupport = T; + long.longIdleTimeOperatingMode = ICD === undefined ? undefined : ICD === 1; + + if ("D" in descriptor) { + long.discriminator = descriptor.D; + } + + if ("CM" in descriptor) { + long.commissioningMode = descriptor.CM; + } + } +} diff --git a/packages/node/src/behavior/system/commissioning/index.ts b/packages/node/src/behavior/system/commissioning/index.ts index a96559218f..c4320da849 100644 --- a/packages/node/src/behavior/system/commissioning/index.ts +++ b/packages/node/src/behavior/system/commissioning/index.ts @@ -4,4 +4,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from "./CommissioningBehavior.js"; +export * from "./CommissioningClient.js"; +export * from "./CommissioningServer.js"; diff --git a/packages/node/src/behavior/system/controller/ControllerBehavior.ts b/packages/node/src/behavior/system/controller/ControllerBehavior.ts new file mode 100644 index 0000000000..c43bec5cd5 --- /dev/null +++ b/packages/node/src/behavior/system/controller/ControllerBehavior.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Behavior } from "#behavior/Behavior.js"; +import { BasicInformationBehavior } from "#behaviors/basic-information"; +import { + Ble, + FabricAuthority, + FabricAuthorityConfigurationProvider, + FabricManager, + MdnsService, + ScannerSet, +} from "#protocol"; +import type { CommissioningClient } from "../commissioning/CommissioningClient.js"; +import { CommissioningServer } from "../commissioning/CommissioningServer.js"; +import { NetworkServer } from "../network/NetworkServer.js"; +import { ActiveDiscoveries } from "./discovery/ActiveDiscoveries.js"; +import type { Discovery } from "./discovery/Discovery.js"; + +/** + * Node controller functionality. + * + * For our purposes, a "controller" is a node that supports commissioning of remote devices. + * + * This class initializes components required for controller usage and tracks active discoveries. Discovery logic + * resides in {@link Discovery} and commissioning logic in {@link CommissioningClient}. + */ +export class ControllerBehavior extends Behavior { + static override readonly id = "controller"; + + declare state: ControllerBehavior.State; + + override async initialize() { + // Configure discovery transports + if (this.state.ip === undefined) { + this.state.ip = true; + } + if (this.state.ip !== false) { + this.env.get(ScannerSet).add((await this.env.load(MdnsService)).scanner); + } + + if (this.state.ble === undefined) { + this.state.ble = (await this.agent.load(NetworkServer)).state.ble; + } + if (this.state.ble !== false) { + this.env.get(ScannerSet).add(Ble.get().getBleScanner()); + } + + // Configure management of controlled fabrics + if (!this.env.has(FabricAuthorityConfigurationProvider)) { + const biState = this.endpoint.stateOf(BasicInformationBehavior); + this.env.set( + FabricAuthorityConfigurationProvider, + new (class extends FabricAuthorityConfigurationProvider { + get vendorId() { + return biState.vendorId; + } + })(), + ); + } + + // "Automatic" controller mode - disable commissioning if node is not otherwise configured as a commissionable + // device + const commissioning = this.agent.get(CommissioningServer); + if (commissioning.state.enabled === undefined) { + const controlledFabrics = this.env.get(FabricAuthority).fabrics.length; + const totalFabrics = this.env.get(FabricManager).length; + if (controlledFabrics === totalFabrics) { + commissioning.state.enabled = false; + } + } + } + + override async [Symbol.asyncDispose]() { + const discoveries = this.env.get(ActiveDiscoveries); + while (discoveries.size) { + for (const discovery of discoveries) { + discovery.cancel(); + } + + await Promise.allSettled([...discoveries]); + } + } +} + +export namespace ControllerBehavior { + export class State { + /** + * Set to false to disable scanning on BLE. + * + * By default the controller scans via BLE if BLE is available. + */ + ble?: boolean = undefined; + + /** + * Set to false to disable scanning on IP networks. + * + * By default the controller always scans on IP networks. + */ + ip?: boolean = undefined; + } +} diff --git a/packages/node/src/behavior/system/controller/discovery/ActiveDiscoveries.ts b/packages/node/src/behavior/system/controller/discovery/ActiveDiscoveries.ts new file mode 100644 index 0000000000..9b18ed6beb --- /dev/null +++ b/packages/node/src/behavior/system/controller/discovery/ActiveDiscoveries.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Environment, Environmental } from "#general"; +import { Discovery } from "./Discovery.js"; + +/** + * Ongoing node discoveries registered with the environment. + */ +export class ActiveDiscoveries extends Set> { + static [Environmental.create](env: Environment) { + const instance = new ActiveDiscoveries(); + env.set(ActiveDiscoveries, instance); + return instance; + } +} diff --git a/packages/node/src/behavior/system/controller/discovery/CommissioningDiscovery.ts b/packages/node/src/behavior/system/controller/discovery/CommissioningDiscovery.ts new file mode 100644 index 0000000000..91e42e45cc --- /dev/null +++ b/packages/node/src/behavior/system/controller/discovery/CommissioningDiscovery.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommissioningClient } from "#behavior/system/commissioning/CommissioningClient.js"; +import { ServerNode } from "#node/ServerNode.js"; +import { Discovery } from "./Discovery.js"; +import { InstanceDiscovery } from "./InstanceDiscovery.js"; + +/** + * Discovers a specific node and commissions it. + */ +export class CommissioningDiscovery extends InstanceDiscovery { + #options: CommissioningDiscovery.Options; + + constructor(owner: ServerNode, options: CommissioningDiscovery.Options) { + super(owner, options); + this.#options = options; + } + + protected override async onComplete() { + const node = await super.onComplete(); + + // TODO - add commissioning flow cancellation once lower-level APIs support it + await node.act("commission", agent => agent.commissioning.commission(this.#options)); + + return node; + } +} + +export namespace CommissioningDiscovery { + export type Options = Discovery.Options & CommissioningClient.CommissioningOptions; +} diff --git a/packages/node/src/behavior/system/controller/discovery/ContinuousDiscovery.ts b/packages/node/src/behavior/system/controller/discovery/ContinuousDiscovery.ts new file mode 100644 index 0000000000..40826fcbb4 --- /dev/null +++ b/packages/node/src/behavior/system/controller/discovery/ContinuousDiscovery.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Observable } from "#general"; +import type { ClientNode } from "#node/ClientNode.js"; +import { ServerNode } from "#node/ServerNode.js"; +import { Discovery } from "./Discovery.js"; + +/** + * Finds all nodes possible within a time window or indefinitely until canceled. + * + * If you run without a timeout, the output array is always empty and you must add a listener to + * {@link ContinuousDiscover#discovered}. + */ +export class ContinuousDiscovery extends Discovery { + #discovered = Observable<[ClientNode]>(); + #result = Array(); + #bounded: boolean; + + constructor(owner: ServerNode, options?: Discovery.Options) { + super(owner, options); + this.#bounded = options?.timeoutSeconds !== undefined; + } + + /** + * Emitted as discovery encounters new nodes. + */ + get discovered() { + return this.#discovered; + } + + protected onDiscovered(node: ClientNode) { + if (this.#bounded) { + this.#result.push(node); + } + this.#discovered.emit(node); + } + + protected onComplete() { + return this.#result; + } +} diff --git a/packages/node/src/behavior/system/controller/discovery/Discovery.ts b/packages/node/src/behavior/system/controller/discovery/Discovery.ts new file mode 100644 index 0000000000..269ffb9a21 --- /dev/null +++ b/packages/node/src/behavior/system/controller/discovery/Discovery.ts @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CancelablePromise, MatterAggregateError, MatterError, MaybePromise } from "#general"; +import { ClientNodeFactory } from "#node/client/ClientNodeFactory.js"; +import { ClientNode } from "#node/ClientNode.js"; +import { ServerNode } from "#node/ServerNode.js"; +import { CommissionableDeviceIdentifiers, ScannerSet } from "#protocol"; +import { ControllerBehavior } from "../ControllerBehavior.js"; +import { ActiveDiscoveries } from "./ActiveDiscoveries.js"; + +export class DiscoveryError extends MatterError { + static override [Symbol.hasInstance](instance: unknown) { + if (instance instanceof DiscoveryAggregateError) { + return true; + } + return super[Symbol.hasInstance](instance); + } +} + +export class DiscoveryAggregateError extends MatterAggregateError {} + +/** + * Discovery of commissionable devices. + * + * This is a cancelable promise; use cancel() to terminate discovery. + */ +export abstract class Discovery extends CancelablePromise { + #isCanceled = false; + #cancel?: () => void; + #owner: ServerNode; + #options: Discovery.Options; + #resolve!: (value: T) => void; + #reject!: (cause?: any) => void; + + constructor(owner: ServerNode, options: Discovery.Options | undefined) { + let resolve: (value: T) => void, reject: (cause?: any) => void; + super((resolver, rejecter) => { + resolve = resolver; + reject = rejecter; + }); + this.#resolve = resolve!; + this.#reject = reject!; + + owner.env.get(ActiveDiscoveries).add(this); + + this.#owner = owner; + this.#options = options ?? {}; + + queueMicrotask(this.#initializeController.bind(this)); + } + + protected abstract onDiscovered(node: ClientNode): void; + protected abstract onComplete(): MaybePromise; + + /** + * Terminate discovery. + * + * This will not abort node initialization but it will terminate any active discoveries. The discovery result will + * be the same as if the discovery had timed out. + */ + override cancel() { + if (this.#isCanceled) { + return; + } + + this.#isCanceled = true; + this.#cancel?.(); + } + + override toString() { + if ("instanceId" in this.#options) { + return `Discovery of node instance ${this.#options.instanceId}`; + } + + if ("longDiscriminator" in this.#options) { + return `Discovery of node with discriminator ${this.#options.longDiscriminator}`; + } + + if ("shortDiscriminator" in this.#options) { + return `Discovery of node with discriminator ${this.#options.shortDiscriminator}`; + } + + if ("productId" in this.#options && this.#options.productId !== undefined) { + if ("vendorId" in this.#options) { + return `Discovery of product ${this.#options.productId} from vendor ${this.#options.vendorId}`; + } + return `Discovery of product ${this.#options.productId}`; + } + + if ("vendorId" in this.#options) { + return `Discovery of node from vendor ${this.#options.vendorId}`; + } + + if ("deviceType" in this.#options) { + return `Discovery of node with device type ${this.#options.deviceType}`; + } + + return "Node discovery"; + } + + /** + * Step 1 - ensure node is initialized as a controller + */ + #initializeController() { + let controllerInitialized; + try { + this.#owner.behaviors.require(ControllerBehavior); + controllerInitialized = this.#owner.act(agent => agent.load(ControllerBehavior)); + } catch (e) { + this.#reject(e); + return; + } + + if (MaybePromise.is(controllerInitialized)) { + controllerInitialized.then(this.#startNode.bind(this), this.#reject); + return; + } + + this.#startNode(); + } + + /** + * Step 2 - ensure node is online + */ + #startNode() { + if (this.#isCanceled) { + this.#invokeCompleter(); + return; + } + + if (this.#owner.lifecycle.isOnline) { + this.#performDiscovery(); + return; + } + + this.#owner.start().then(this.#performDiscovery.bind(this), this.#reject); + } + + /** + * Step 3 - perform actual discovery + */ + #performDiscovery() { + if (this.#isCanceled) { + this.#invokeCompleter(); + return; + } + + const scanners = this.#owner.env.get(ScannerSet); + + const factory = this.#owner.env.get(ClientNodeFactory); + const promises = new Array>(); + const cancelSignal = new Promise(resolve => (this.#cancel = resolve)); + for (const scanner of scanners) { + promises.push( + scanner.findCommissionableDevicesContinuously( + this.#options, + descriptor => { + // Identify a known node that matches the descriptor + let node = factory.find(descriptor); + + if (node) { + // Found a known node; update its commissioning metadata + const updatePromise = node.act(agent => { + agent.commissioning.descriptor = descriptor; + }); + if (MaybePromise.is(updatePromise)) { + promises.push(updatePromise); + } + } else { + // This node is new to us + node = factory.create({ + environment: this.#owner.env, + commissioning: { descriptor }, + }); + } + + this.onDiscovered(node); + }, + this.#options.timeoutSeconds, + cancelSignal, + ), + ); + } + + Promise.allSettled(promises) + .then(results => { + const errors = Array(); + + for (const result of results) { + if (result.status === "rejected") { + errors.push(result.reason); + } + } + + if (errors.length) { + throw new DiscoveryAggregateError(errors, `${this} failed`); + } + + this.#invokeCompleter(); + }) + .catch(this.#reject); + } + + /** + * Step 4 - invoke completion callback + */ + #invokeCompleter() { + let result: MaybePromise; + try { + result = this.onComplete(); + } catch (e) { + this.#reject(e); + return; + } + + if (MaybePromise.is(result)) { + result.then(this.#finish.bind(this), this.#reject); + return; + } + + this.#finish(result); + } + + /** + * Step 5 - deregister from environment and resolve + */ + #finish(result: T) { + this.#owner.env.get(ActiveDiscoveries).delete(this); + this.#resolve(result); + } +} + +export namespace Discovery { + export type Options = CommissionableDeviceIdentifiers & { + timeoutSeconds?: number; + }; +} diff --git a/packages/node/src/behavior/system/controller/discovery/InstanceDiscovery.ts b/packages/node/src/behavior/system/controller/discovery/InstanceDiscovery.ts new file mode 100644 index 0000000000..965d348578 --- /dev/null +++ b/packages/node/src/behavior/system/controller/discovery/InstanceDiscovery.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MaybePromise } from "#general"; +import type { ClientNode } from "#node/ClientNode.js"; +import { ServerNode } from "#node/ServerNode.js"; +import { Discovery, DiscoveryError } from "./Discovery.js"; + +/** + * Locates a single node and returns it. + * + * Throws an error if the node is not located. + */ +export class InstanceDiscovery extends Discovery { + #result?: ClientNode; + + constructor(owner: ServerNode, options?: Discovery.Options) { + super(owner, options); + } + + protected onDiscovered(node: ClientNode) { + this.#result = node; + this.cancel(); + } + + protected onComplete(): MaybePromise { + if (this.#result === undefined) { + throw new DiscoveryError(`${this} failed: Node not found`); + } + return this.#result; + } +} diff --git a/packages/node/src/behavior/system/controller/discovery/index.ts b/packages/node/src/behavior/system/controller/discovery/index.ts new file mode 100644 index 0000000000..596e083a4a --- /dev/null +++ b/packages/node/src/behavior/system/controller/discovery/index.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./ActiveDiscoveries.js"; +export * from "./CommissioningDiscovery.js"; +export * from "./ContinuousDiscovery.js"; +export * from "./Discovery.js"; +export * from "./InstanceDiscovery.js"; diff --git a/packages/node/src/behavior/system/controller/index.ts b/packages/node/src/behavior/system/controller/index.ts new file mode 100644 index 0000000000..0de9ec76ac --- /dev/null +++ b/packages/node/src/behavior/system/controller/index.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./ControllerBehavior.js"; +export * from "./discovery/index.js"; diff --git a/packages/node/src/behavior/system/index.ts b/packages/node/src/behavior/system/index.ts index 98e55ad6b8..9c9dd5ed0a 100644 --- a/packages/node/src/behavior/system/index.ts +++ b/packages/node/src/behavior/system/index.ts @@ -5,6 +5,7 @@ */ export * from "./commissioning/index.js"; +export * from "./controller/index.js"; export * from "./index/index.js"; export * from "./network/index.js"; export * from "./parts/index.js"; diff --git a/packages/node/src/behavior/system/network/NetworkServer.ts b/packages/node/src/behavior/system/network/NetworkServer.ts index 966b0fa43d..61b27a57a4 100644 --- a/packages/node/src/behavior/system/network/NetworkServer.ts +++ b/packages/node/src/behavior/system/network/NetworkServer.ts @@ -7,7 +7,7 @@ import { ImplementationError, Logger } from "#general"; import { Ble, ServerSubscriptionConfig } from "#protocol"; import { DiscoveryCapabilitiesBitmap, TypeFromPartialBitSchema } from "#types"; -import { CommissioningBehavior } from "../commissioning/CommissioningBehavior.js"; +import { CommissioningServer } from "../commissioning/CommissioningServer.js"; import { NetworkBehavior } from "./NetworkBehavior.js"; import { ServerNetworkRuntime } from "./ServerNetworkRuntime.js"; @@ -48,7 +48,7 @@ export class NetworkServer extends NetworkBehavior { discoveryCaps.onIpNetwork = true; } - this.reactTo(this.agent.get(CommissioningBehavior).events.commissioned, this.#endUncommissionedMode); + this.reactTo(this.agent.get(CommissioningServer).events.commissioned, this.#endUncommissionedMode); return super.initialize(); } @@ -57,8 +57,8 @@ export class NetworkServer extends NetworkBehavior { * Advertise and continue advertising at regular intervals until timeout per Matter specification. If already * advertising, the advertisement timeout resets. * - * If the node is uncommissioned it announces as commissionable on all available transports. Commissioned devices - * only advertise for operational discovery via DNS-SD. + * If the node is uncommissioned and commissioning is enabled, announces as commissionable on all available + * transports. Commissioned devices only advertise for operational discovery via DNS-SD. * * Advertisement begins at startup. */ diff --git a/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts b/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts index ed84e4d9c1..6824c52ec9 100644 --- a/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts +++ b/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts @@ -30,7 +30,8 @@ import { SecureChannelProtocol, SessionManager, } from "#protocol"; -import { CommissioningBehavior } from "../commissioning/CommissioningBehavior.js"; +import { CommissioningOptions } from "@matter/types"; +import { CommissioningServer } from "../commissioning/CommissioningServer.js"; import { ProductDescriptionServer } from "../product-description/ProductDescriptionServer.js"; import { SessionsBehavior } from "../sessions/SessionsBehavior.js"; import { NetworkRuntime } from "./NetworkRuntime.js"; @@ -271,27 +272,39 @@ export class ServerNetworkRuntime extends NetworkRuntime { await this.owner.act("load-sessions", agent => agent.load(SessionsBehavior)); - // Monitor CommissioningBehavior to end "uncommissioned" mode when we are commissioned - this.#observers.on(this.owner.eventsOf(CommissioningBehavior).commissioned, this.endUncommissionedMode); + // Monitor CommissioningServer to end "uncommissioned" mode when we are commissioned + this.#observers.on(this.owner.eventsOf(CommissioningServer).commissioned, this.endUncommissionedMode); // Ensure the environment will convey the commissioning configuration to the DeviceCommissioner if (!env.has(CommissioningConfigProvider)) { + // When first going online, enable commissioning by controllers unless we ourselves are configured as a + // controller + if (owner.state.commissioning.enabled === undefined) { + await owner.set({ + commissioning: { enabled: true }, + }); + } + + // Configure the DeviceCommissioner env.set( CommissioningConfigProvider, new (class extends CommissioningConfigProvider { get values() { - return { + const config = { ...owner.state.commissioning, productDescription: owner.state.productDescription, ble: !!owner.state.network.ble, }; + + return config as CommissioningOptions.Configuration; } })(), ); } - // Initialize the DeviceCommissioner - env.get(DeviceCommissioner); + // Ensure there is a device commissioner if (but only if) commissioning is enabled + await this.configureCommissioning(); + this.#observers.on(this.owner.eventsOf(CommissioningServer).enabled$Changed, this.configureCommissioning); await this.openAdvertisementWindow(); } @@ -312,4 +325,14 @@ export class ServerNetworkRuntime extends NetworkRuntime { protected override blockNewActivity() { this.#interactionServer?.blockNewActivity(); } + + protected async configureCommissioning() { + if (this.owner.state.commissioning.enabled) { + // Ensure a DeviceCommissioner is active + this.owner.env.get(DeviceCommissioner); + } else if (this.owner.env.has(DeviceCommissioner)) { + // Ensure no DeviceCommissioner is active + await this.owner.env.close(DeviceCommissioner); + } + } } diff --git a/packages/node/src/behaviors/access-control/AccessControlServer.ts b/packages/node/src/behaviors/access-control/AccessControlServer.ts index 9b0da96005..590c034d14 100644 --- a/packages/node/src/behaviors/access-control/AccessControlServer.ts +++ b/packages/node/src/behaviors/access-control/AccessControlServer.ts @@ -55,7 +55,7 @@ export class AccessControlServer extends AccessControlBehavior { #online() { // Handle Backward compatibility to Matter.js before 0.9.1 and add the missing ACL entry if no entry was set // so far by the controller - const fabrics = this.env.get(FabricManager).getFabrics(); + const fabrics = this.env.get(FabricManager); const acl = deepCopy(this.state.acl); const originalAclLength = acl.length; for (const fabric of fabrics) { diff --git a/packages/node/src/behaviors/operational-credentials/OperationalCredentialsServer.ts b/packages/node/src/behaviors/operational-credentials/OperationalCredentialsServer.ts index 1f82035b3f..d2edbb7af4 100644 --- a/packages/node/src/behaviors/operational-credentials/OperationalCredentialsServer.ts +++ b/packages/node/src/behaviors/operational-credentials/OperationalCredentialsServer.ts @@ -6,7 +6,7 @@ import { Val } from "#behavior/state/Val.js"; import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js"; -import { CommissioningBehavior } from "#behavior/system/commissioning/CommissioningBehavior.js"; +import { CommissioningServer } from "#behavior/system/commissioning/CommissioningServer.js"; import { ProductDescriptionServer } from "#behavior/system/product-description/ProductDescriptionServer.js"; import { AccessControlServer } from "#behaviors/access-control"; import { AccessControl } from "#clusters/access-control"; @@ -360,9 +360,7 @@ export class OperationalCredentialsServer extends OperationalCredentialsBehavior const currentFabricIndex = fabric.fabricIndex; const fabrics = this.env.get(FabricManager); - const conflictingLabelFabric = fabrics - .getFabrics() - .find(f => f.label === label && f.fabricIndex !== currentFabricIndex); + const conflictingLabelFabric = fabrics.find(f => f.label === label && f.fabricIndex !== currentFabricIndex); if (conflictingLabelFabric !== undefined) { return { statusCode: OperationalCredentials.NodeOperationalCertStatus.LabelConflict, @@ -426,14 +424,14 @@ export class OperationalCredentialsServer extends OperationalCredentialsBehavior throw error; } - const fabrics = this.env.get(FabricManager).getFabrics(); + const fabrics = this.env.get(FabricManager); const trustedRootCertificates = fabrics.map(fabric => fabric.rootCert); trustedRootCertificates.push(rootCaCertificate); this.state.trustedRootCertificates = trustedRootCertificates; } async #updateFabrics() { - const fabrics = this.env.get(FabricManager).getFabrics(); + const fabrics = this.env.get(FabricManager); this.state.fabrics = fabrics.map(fabric => ({ fabricId: fabric.fabricId, label: fabric.label, @@ -471,17 +469,17 @@ export class OperationalCredentialsServer extends OperationalCredentialsBehavior async #handleAddedFabric({ fabricIndex }: Fabric) { await this.#updateFabrics(); - this.agent.get(CommissioningBehavior).handleFabricChange(fabricIndex, FabricAction.Added); + this.agent.get(CommissioningServer).handleFabricChange(fabricIndex, FabricAction.Added); } async #handleUpdatedFabric({ fabricIndex }: Fabric) { await this.#updateFabrics(); - this.agent.get(CommissioningBehavior).handleFabricChange(fabricIndex, FabricAction.Updated); + this.agent.get(CommissioningServer).handleFabricChange(fabricIndex, FabricAction.Updated); } async #handleRemovedFabric({ fabricIndex }: Fabric) { await this.#updateFabrics(); - this.agent.get(CommissioningBehavior).handleFabricChange(fabricIndex, FabricAction.Removed); + this.agent.get(CommissioningServer).handleFabricChange(fabricIndex, FabricAction.Removed); } async #handleFailsafeClosed() { diff --git a/packages/node/src/endpoint/Endpoint.ts b/packages/node/src/endpoint/Endpoint.ts index e1e9dac5e8..8682a4414b 100644 --- a/packages/node/src/endpoint/Endpoint.ts +++ b/packages/node/src/endpoint/Endpoint.ts @@ -588,6 +588,22 @@ export class Endpoint { } } + /** + * Perform "hard" reset of the endpoint, reverting all in-memory and persistent state to uninitialized. + */ + async erase() { + await this.reset(); + await this.env.get(EndpointInitializer).eraseDescendant(this); + } + + /** + * Erase all persisted data and destroy the node. + */ + async delete() { + await this.erase(); + await this.close(); + } + /** * Apply a depth-first visitor function to myself and all descendents. */ @@ -663,7 +679,7 @@ export class Endpoint { */ protected initialize() { // Configure the endpoint for the appropriate node type - this.env.get(EndpointInitializer).initializeDescendent(this); + this.env.get(EndpointInitializer).initializeDescendant(this); // Initialize behaviors. Success brings endpoint to "ready" state let promise = this.behaviors.initialize(); diff --git a/packages/node/src/endpoint/EndpointServer.ts b/packages/node/src/endpoint/EndpointServer.ts index 56cac56334..9f4af10746 100644 --- a/packages/node/src/endpoint/EndpointServer.ts +++ b/packages/node/src/endpoint/EndpointServer.ts @@ -53,7 +53,7 @@ export class EndpointServer implements EndpointInterface { backing = new ClusterServerBacking(this, type as ClusterBehavior.Type); } else { - backing = new ServerBehaviorBacking(this.#endpoint, type); + backing = new ServerBehaviorBacking(this.#endpoint, type, this.#endpoint.behaviors.optionsFor(type)); } return backing; } diff --git a/packages/node/src/endpoint/properties/Behaviors.ts b/packages/node/src/endpoint/properties/Behaviors.ts index 87dc207972..758b436d3c 100644 --- a/packages/node/src/endpoint/properties/Behaviors.ts +++ b/packages/node/src/endpoint/properties/Behaviors.ts @@ -446,6 +446,13 @@ export class Behaviors { return defaults; } + /** + * Retrieve the options for a behavior type provided to the endpoint. + */ + optionsFor(type: Behavior.Type) { + return this.#options[type.id]; + } + /** * Access internal state for a {@link Behavior}. * diff --git a/packages/node/src/endpoint/properties/EndpointContainer.ts b/packages/node/src/endpoint/properties/EndpointContainer.ts index 288086f57f..1727f979a3 100644 --- a/packages/node/src/endpoint/properties/EndpointContainer.ts +++ b/packages/node/src/endpoint/properties/EndpointContainer.ts @@ -4,19 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BasicSet, MutableSet, ObservableSet } from "#general"; +import { BasicSet, decamelize, Diagnostic, ImmutableSet, MutableSet, ObservableSet } from "#general"; import { IdentityConflictError } from "#node/server/IdentityService.js"; import { Endpoint } from "../Endpoint.js"; /** * Manages parent-child relationships between endpoints. */ -export class EndpointContainer implements MutableSet, ObservableSet { +export class EndpointContainer + implements ImmutableSet, MutableSet, ObservableSet +{ #children = new BasicSet(); - #endpoint: Endpoint; + #owner: Endpoint; constructor(endpoint: Endpoint) { - this.#endpoint = endpoint; + this.#owner = endpoint; } get(id: string) { @@ -33,7 +35,7 @@ export class EndpointContainer implements Mutable } this.#children.add(endpoint); - endpoint.owner = this.#endpoint; + endpoint.owner = this.#owner; endpoint.lifecycle.destroyed.once(() => { this.delete(endpoint); @@ -66,14 +68,18 @@ export class EndpointContainer implements Mutable return this.#children.size; } - map(fn: (part: Endpoint) => T) { + map(fn: (part: T) => T2) { return this.#children.map(fn); } - filter(predicate: (part: Endpoint) => boolean) { + filter(predicate: (part: T) => boolean) { return this.#children.filter(predicate); } + find(predicate: (part: T) => boolean) { + return this.#children.find(predicate); + } + [Symbol.iterator]() { return this.#children[Symbol.iterator](); } @@ -96,10 +102,6 @@ export class EndpointContainer implements Mutable } } - /** - * Ensure the endpoint's identity is unique amongst siblings. - */ - /** * Confirm availability of an ID amongst the endpoint's children. */ @@ -110,7 +112,11 @@ export class EndpointContainer implements Mutable } } - protected get endpoint() { - return this.#endpoint; + protected get owner() { + return this.#owner; + } + + get [Diagnostic.value]() { + return Diagnostic.list([decamelize(this.constructor.name), ...this]); } } diff --git a/packages/node/src/endpoint/properties/EndpointInitializer.ts b/packages/node/src/endpoint/properties/EndpointInitializer.ts index 0e98e7b8a1..5742fd99c9 100644 --- a/packages/node/src/endpoint/properties/EndpointInitializer.ts +++ b/packages/node/src/endpoint/properties/EndpointInitializer.ts @@ -15,14 +15,18 @@ export abstract class EndpointInitializer { /** * Initialize a {@link Endpoint}. */ - initializeDescendent(_endpoint: Endpoint) {} + initializeDescendant(_endpoint: Endpoint) {} + + /** + * Erase storage for a {@link Endpoint}. + */ + abstract eraseDescendant(_endpoint: Endpoint): Promise; /** * Create backing for a behavior of a descendent. * * @param endpoint the {@link Endpoint} the behavior belongs to * @param type the {@link Behavior} type - * @param defaults default values for behavior state * @returns a new {@link BehaviorBacking} */ abstract createBacking(endpoint: Endpoint, behavior: Behavior.Type): BehaviorBacking; diff --git a/packages/node/src/endpoint/properties/Parts.ts b/packages/node/src/endpoint/properties/Parts.ts index 2d8c2d0dd4..a37bd24ffb 100644 --- a/packages/node/src/endpoint/properties/Parts.ts +++ b/packages/node/src/endpoint/properties/Parts.ts @@ -29,7 +29,7 @@ export class Parts extends EndpointContainer implements MutableSet lifecycle.bubble(type, endpoint); } @@ -43,7 +43,7 @@ export class Parts extends EndpointContainer implements MutableSet this.#bubbleChange(type, endpoint)); // If the part is already fully initialized we initialize the child now - if (this.endpoint.lifecycle.isPartsReady) { + if (this.owner.lifecycle.isPartsReady) { if (!endpoint.construction.isErrorHandled) { endpoint.construction.onError(error => logger.error(`Error initializing ${endpoint}:`, error)); } @@ -80,7 +80,7 @@ export class Parts extends EndpointContainer implements MutableSet this.endpoint.lifecycle.change(EndpointLifecycle.Change.PartsReady); + const onPartsReady = () => this.owner.lifecycle.change(EndpointLifecycle.Change.PartsReady); if (!this.size) { onPartsReady(); return; @@ -169,7 +169,7 @@ export class Parts extends EndpointContainer implements MutableSet) { if (endpoint.lifecycle.hasNumber) { - this.endpoint.env.get(IdentityService).assertNumberAvailable(endpoint.number, endpoint); + this.owner.env.get(IdentityService).assertNumberAvailable(endpoint.number, endpoint); if (usedNumbers?.has(endpoint.number)) { throw new IdentityConflictError( `Cannot add endpoint ${forefather} because descendents have conflicting definitions for endpoint number ${endpoint.number}`, @@ -207,11 +207,11 @@ export class Parts extends EndpointContainer implements MutableSet = {}; - #store: ClientNodeStore; - - constructor({ owner, store }: ClientNode.Options) { - const { address } = store; - super({ - id: `${address.fabricIndex}:${address.nodeId.toString(16)}`, +export class ClientNode extends Node { + constructor(options: ClientNode.Options) { + const opts = { + ...options, number: 0, - type: Node.CommonRootEndpoint, - owner, - }); - this.#address = store.address; - this.#store = store; + type: ClientNode.RootEndpoint, + }; - this.env.set(EndpointInitializer, new ClientEndpointInitializer(store)); + super(opts); } override async initialize() { - await super.initialize(); - - const discovery = this.#store.discoveryStorage; - const operationalAddress = {} as Record; - for (const key of await discovery.keys()) { - operationalAddress[key] = await discovery.get(key); - } - this.#operationalAddress = operationalAddress as Partial; - } - - get address() { - return this.#address; - } - - get operationalAddress(): OperationalPeer { - return { ...this.#operationalAddress, address: this.#address }; - } + this.env.set(EndpointInitializer, await ClientEndpointInitializer.create(this)); - updateOperationalAddress(operationalAddress: Partial) { - operationalAddress = { ...operationalAddress }; - delete operationalAddress.address; - this.#operationalAddress = operationalAddress; - return this.#store.discoveryStorage.set(operationalAddress as Record); + await super.initialize(); } - override get lifecycle() { - return super.lifecycle as ClientNodeLifecycle; + override get owner(): ServerNode | undefined { + return super.owner as ServerNode | undefined; } - override get owner(): ServerNode { - return super.owner as ServerNode; + override set owner(owner: ServerNode) { + super.owner = owner; } - protected override set owner(node: ServerNode) { - if (!(node instanceof ServerNode)) { - throw new ImplementationError("Client node owner must be a server node"); - } - super.owner = node; + async commission(options: CommissioningClient.CommissioningOptions) { + await this.act("commission", agent => agent.commissioning.commission(options)); } - createRuntime(): NetworkRuntime { + protected createRuntime(): NetworkRuntime { throw new NotImplementedError(); } @@ -85,14 +56,33 @@ export class ClientNode extends Node { return this.owner?.nodes; } - protected override createLifecycle() { - return new ClientNodeLifecycle(this); + override act( + purpose: string, + actor: (agent: Agent.Instance) => MaybePromise, + ): MaybePromise; + + override act(actor: (agent: Agent.Instance) => MaybePromise): MaybePromise; + + override act( + actorOrPurpose: string | ((agent: Agent.Instance) => MaybePromise), + actor?: (agent: Agent.Instance) => MaybePromise, + ): MaybePromise { + if (this.construction.status === Lifecycle.Status.Inactive) { + this.construction.start(); + } + + if (this.construction.status === Lifecycle.Status.Initializing) { + return this.construction.then(() => (super.act as any)(actorOrPurpose, actor)); + } + + return (super.act as any)(actorOrPurpose, actor); } } export namespace ClientNode { - export interface Options extends Node.Options { - owner: ServerNode; - store: ClientNodeStore; - } + export interface Options extends Node.Options {} + + export const RootEndpoint = Node.CommonRootEndpoint.with(CommissioningClient); + + export interface RootEndpoint extends Identity {} } diff --git a/packages/node/src/node/ClientNodeLifecycle.ts b/packages/node/src/node/ClientNodeLifecycle.ts deleted file mode 100644 index 64e2790848..0000000000 --- a/packages/node/src/node/ClientNodeLifecycle.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @license - * Copyright 2022-2024 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { NodeLifecycle } from "./NodeLifecycle.js"; - -export class ClientNodeLifecycle extends NodeLifecycle { - discovery: unknown; // TODO -} diff --git a/packages/node/src/node/Node.ts b/packages/node/src/node/Node.ts index b9374e6214..d38c2c3311 100644 --- a/packages/node/src/node/Node.ts +++ b/packages/node/src/node/Node.ts @@ -57,8 +57,6 @@ export abstract class Node { this.statusUpdate("is online"); }); diff --git a/packages/node/src/node/Nodes.ts b/packages/node/src/node/Nodes.ts deleted file mode 100644 index 423dc2ad6d..0000000000 --- a/packages/node/src/node/Nodes.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @license - * Copyright 2022-2024 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { EndpointContainer } from "#endpoint/properties/EndpointContainer.js"; -import { Construction, InternalError } from "#general"; -import { - ControllerCommissioner, - Fabric, - FabricAuthority, - FabricAuthorityConfigurationProvider, - OperationalPeer, - PeerAddress, - PeerAddressStore, - PeerCommissioningOptions, - PeerDataStore, -} from "#protocol"; -import { ClientNode } from "./ClientNode.js"; -import { type Node } from "./Node.js"; -import { ServerNode } from "./ServerNode.js"; -import { ServerNodeStore } from "./storage/ServerNodeStore.js"; - -/** - * Manages the set of known endpoints that share a fabric with a {@link Node}. - */ -export class Nodes extends EndpointContainer { - #construction: Construction; - #controllerFabric?: Fabric; - - get construction() { - return this.#construction; - } - - constructor(owner: ServerNode) { - super(owner); - - this.#configureController(); - - this.#construction = Construction(this, async () => { - const stores = await this.endpoint.env.get(ServerNodeStore).allPeerStores(); - for (const store of stores) { - this.add( - new ClientNode({ - owner, - store, - }), - ); - } - }); - } - - override get(id: string | PeerAddress) { - if (typeof id !== "string") { - id = PeerAddress(id).toString(); - } - return super.get(id); - } - - set controllerFabric(fabric: Fabric) { - this.#controllerFabric = fabric; - } - - override get endpoint() { - return super.endpoint as ServerNode; - } - - async commission(options: Nodes.CommissioningOptions) { - await this.#construction; - - let commissionerOptions: PeerCommissioningOptions; - if (options.fabric !== undefined) { - commissionerOptions = options as PeerCommissioningOptions; - } else { - let controllerFabric = this.#controllerFabric; - if (controllerFabric === undefined) { - controllerFabric = this.#controllerFabric = await this.endpoint.env - .get(FabricAuthority) - .defaultFabric(); - } - commissionerOptions = { - ...options, - fabric: controllerFabric, - }; - } - - const commissioner = this.endpoint.env.get(ControllerCommissioner); - - const address = await commissioner.commission(commissionerOptions); - const node = this.get(address); - if (node === undefined) { - throw new InternalError(`Commissioned node ${PeerAddress(address)} but no ClientNode installed`); - } - - return node; - } - - #configureController() { - const { endpoint: owner } = this; - - if (!owner.env.has(FabricAuthorityConfigurationProvider)) { - owner.env.set( - FabricAuthorityConfigurationProvider, - new (class extends FabricAuthorityConfigurationProvider { - get vendorId() { - return owner.state.basicInformation.vendorId; - } - })(), - ); - } - - const nodes = this; - - owner.env.set( - PeerAddressStore, - new (class extends PeerAddressStore { - async loadPeers() { - await nodes.construction; - return [...nodes].map(node => ({ address: node.address })); - } - - async updatePeer(peer: OperationalPeer) { - await nodes.construction; - const node = nodes.get(peer.address); - await node?.updateOperationalAddress(peer); - } - - async deletePeer(address: PeerAddress) { - await nodes.construction; - const node = nodes.get(address); - if (node) { - nodes.delete(node); - } - } - - async createNodeStore(): Promise { - throw new InternalError("Node store creation not supported"); - } - })(), - ); - } -} - -export namespace Nodes { - export interface CommissioningOptions extends Omit { - fabric?: Fabric; - } -} diff --git a/packages/node/src/node/ServerNode.ts b/packages/node/src/node/ServerNode.ts index f66dff69f3..ac8cbee0cb 100644 --- a/packages/node/src/node/ServerNode.ts +++ b/packages/node/src/node/ServerNode.ts @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CommissioningBehavior } from "#behavior/system/commissioning/CommissioningBehavior.js"; +import { CommissioningServer } from "#behavior/system/commissioning/CommissioningServer.js"; +import { ControllerBehavior } from "#behavior/system/controller/ControllerBehavior.js"; import { NetworkServer } from "#behavior/system/network/NetworkServer.js"; import { ServerNetworkRuntime } from "#behavior/system/network/ServerNetworkRuntime.js"; import { ProductDescriptionServer } from "#behavior/system/product-description/ProductDescriptionServer.js"; @@ -16,7 +17,7 @@ import { Construction, DiagnosticSource, Identity, MatterError, asyncNew, errorO import { EventHandler, FabricManager, SessionManager } from "#protocol"; import { RootEndpoint as BaseRootEndpoint } from "../endpoints/root.js"; import { Node } from "./Node.js"; -import { Nodes } from "./Nodes.js"; +import { ClientNodes } from "./client/ClientNodes.js"; import { ServerEnvironment } from "./server/ServerEnvironment.js"; import { ServerNodeStore } from "./storage/ServerNodeStore.js"; @@ -36,7 +37,7 @@ class FactoryResetError extends MatterError { * The Matter specification often refers to server-side nodes as "devices". */ export class ServerNode extends Node { - #nodes?: Nodes; + #nodes?: ClientNodes; /** * Construct a new ServerNode. @@ -60,7 +61,11 @@ export class ServerNode, options?: Node.Options) { super(Node.nodeConfigFor(ServerNode.RootEndpoint as T, definition, options)); + this.env.set(ServerNode, this); + DiagnosticSource.add(this); + + this.construction.start(); } /** @@ -110,7 +115,7 @@ export class ServerNode {} diff --git a/packages/node/src/node/client/ClientEndpointInitializer.ts b/packages/node/src/node/client/ClientEndpointInitializer.ts index 35442b8e64..f8bfe34e75 100644 --- a/packages/node/src/node/client/ClientEndpointInitializer.ts +++ b/packages/node/src/node/client/ClientEndpointInitializer.ts @@ -9,18 +9,46 @@ import { BehaviorBacking } from "#behavior/internal/BehaviorBacking.js"; import { ClientBehaviorBacking } from "#behavior/internal/ClientBehaviorBacking.js"; import { Endpoint } from "#endpoint/Endpoint.js"; import { EndpointInitializer } from "#endpoint/properties/EndpointInitializer.js"; -import { ClientNodeStore } from "#node/storage/ClientNodeStore.js"; +import type { ClientNode } from "#node/ClientNode.js"; +import { NodeStore } from "#node/storage/NodeStore.js"; +import { ServerNodeStore } from "#node/storage/ServerNodeStore.js"; export class ClientEndpointInitializer extends EndpointInitializer { - #store: ClientNodeStore; + #node: ClientNode; + #store: NodeStore; - constructor(store: ClientNodeStore) { + constructor(node: ClientNode) { super(); - this.#store = store; + this.#node = node; + this.#store = node.env.get(ServerNodeStore).clientStores.storeForNode(node); + } + + async eraseDescendant(endpoint: Endpoint) { + if (endpoint === this.#node) { + await this.#store.erase(); + return; + } + + if (!endpoint.lifecycle.hasId) { + return; + } + + const store = this.#store.endpointStores.storeForEndpoint(endpoint); + await store.erase(); + } + + get ready() { + return this.#store.construction.ready; + } + + static async create(node: ClientNode) { + const instance = new ClientEndpointInitializer(node); + await instance.ready; + return instance; } override createBacking(endpoint: Endpoint, behavior: Behavior.Type): BehaviorBacking { const store = this.#store.endpointStores.storeForEndpoint(endpoint); - return new ClientBehaviorBacking(endpoint, behavior, store); + return new ClientBehaviorBacking(endpoint, behavior, store, endpoint.behaviors.optionsFor(behavior)); } } diff --git a/packages/node/src/node/client/ClientNodeFactory.ts b/packages/node/src/node/client/ClientNodeFactory.ts new file mode 100644 index 0000000000..e59d11ed1e --- /dev/null +++ b/packages/node/src/node/client/ClientNodeFactory.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RemoteDescriptor } from "#behavior/system/commissioning/RemoteDescriptor.js"; +import { ImmutableSet } from "#general"; +import type { ClientNode } from "#node/ClientNode.js"; + +/** + * Create a new client node. + * + * You may provide a factory in a node's environment to specialize ClientNode initialization. If you do so you must + * install new nodes in the ServerNode's "nodes" property. + */ +export abstract class ClientNodeFactory { + abstract find(descriptor: RemoteDescriptor): ClientNode | undefined; + abstract create(options: ClientNode.Options): ClientNode; + abstract nodes: ImmutableSet; +} diff --git a/packages/node/src/node/client/ClientNodes.ts b/packages/node/src/node/client/ClientNodes.ts new file mode 100644 index 0000000000..ffed555f86 --- /dev/null +++ b/packages/node/src/node/client/ClientNodes.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommissioningDiscovery, ContinuousDiscovery, Discovery, InstanceDiscovery } from "#behavior/index.js"; +import { RemoteDescriptor } from "#behavior/system/commissioning/RemoteDescriptor.js"; +import { EndpointContainer } from "#endpoint/properties/EndpointContainer.js"; +import { CancelablePromise, Lifespan, Time } from "#general"; +import { ServerNodeStore } from "#node/storage/ServerNodeStore.js"; +import { PeerAddress, PeerAddressStore } from "#protocol"; +import { ClientNode } from "../ClientNode.js"; +import type { ServerNode } from "../ServerNode.js"; +import { ClientNodeFactory } from "./ClientNodeFactory.js"; +import { NodePeerStore } from "./NodePeerStore.js"; + +const DEFAULT_TTL = 900 * 1000; +const EXPIRATION_INTERVAL = 60 * 1000; + +/** + * Manages the set of known remote nodes. + * + * Remote nodes are either peers (commissioned into a fabric we share) or commissionable. + */ +export class ClientNodes extends EndpointContainer { + #expirationInterval: CancelablePromise | undefined; + + constructor(owner: ServerNode) { + super(owner); + + if (!owner.env.has(ClientNodeFactory)) { + owner.env.set(ClientNodeFactory, new Factory(this)); + } + + this.owner.env.set(PeerAddressStore, new NodePeerStore(owner)); + + this.added.on(this.#manageExpiration.bind(this)); + this.deleted.on(this.#manageExpiration.bind(this)); + } + + /** + * Load nodes. Invoked automatically by owner. + */ + initialize() { + const factory = this.owner.env.get(ClientNodeFactory); + + const clientStores = this.owner.env.get(ServerNodeStore).clientStores; + for (const id of clientStores.knownIds) { + this.add( + factory.create({ + id, + owner: this.owner, + }), + ); + } + } + + /** + * Find a specific commissionable node. + */ + locate(options?: Discovery.Options) { + return new InstanceDiscovery(this.owner, options); + } + + /** + * Employ discovery to find a set of commissionable nodes. + * + * If you do not provide a timeout value, will search until canceled and you need to add a listener to + * {@link Discovery#discovered} or {@link added} to receive discovered nodes. + */ + discover(options?: Discovery.Options) { + return new ContinuousDiscovery(this.owner, options); + } + + /** + * Find a specific commissionable node and commission. + */ + commission(passcode: number, discriminator?: number): Promise; + + /** + * Find a specific commissionable node and commission. + */ + commission(options: CommissioningDiscovery.Options): Promise; + + commission(optionsOrPasscode: CommissioningDiscovery.Options | number, discriminator?: number) { + if (typeof optionsOrPasscode !== "object") { + optionsOrPasscode = { passcode: optionsOrPasscode }; + } + + if (discriminator !== undefined) { + (optionsOrPasscode as { longDiscriminator: number }).longDiscriminator = discriminator; + } + + return new CommissioningDiscovery(this.owner, optionsOrPasscode); + } + + override get(id: string | PeerAddress) { + if (typeof id !== "string") { + const address = PeerAddress(id); + for (const node of this) { + const nodeAddress = node.state.commissioning.peerAddress; + if (nodeAddress && PeerAddress(nodeAddress) === address) { + return node; + } + } + return undefined; + } + + return super.get(id); + } + + override get owner() { + return super.owner as ServerNode; + } + + override add(node: ClientNode) { + node.owner = this.owner; + + super.add(node); + } + + override async close() { + this.#cancelExpiration(); + await super.close(); + } + + #cancelExpiration() { + if (this.#expirationInterval) { + this.#expirationInterval.cancel(); + this.#expirationInterval = undefined; + } + } + + #manageExpiration() { + if (this.#expirationInterval) { + if (!this.size) { + this.#cancelExpiration(); + } + return; + } + + if (!this.size) { + return; + } + + this.#expirationInterval = Time.sleep("client node expiration", EXPIRATION_INTERVAL).then(async () => { + this.#expirationInterval = undefined; + try { + await this.#cullExpiredNodesAndAddresses(); + } finally { + this.#manageExpiration(); + } + }); + } + + async #cullExpiredNodesAndAddresses() { + const now = Time.nowMs(); + + for (const node of this) { + const state = node.state.commissioning; + const { addresses } = state; + const isCommissioned = state.peerAddress !== undefined; + + // Shortcut for conditions we know no change is possible + if (addresses === undefined || (isCommissioned && addresses.length === 1)) { + return; + } + + // Remove expired addresses + let newAddresses = addresses.filter(addr => { + const exp = expirationOf(addr); + if (exp === undefined) { + return true; + } + + return exp > now; + }); + + // Cull commissionable nodes that have expired + if (!isCommissioned) { + if (!newAddresses?.length || expirationOf(state) <= now) { + await node.delete(); + continue; + } + } + + // If the node is commissioned, do not remove the last address. Instead keep the "least expired" addresses + if (isCommissioned && addresses.length && !newAddresses.length) { + if (addresses.length === 1) { + return; + } + const freshestExp = addresses.reduce((freshestExp, addr) => { + return Math.max(freshestExp, expirationOf(addr)!); + }, 0); + + newAddresses = addresses.filter(addr => expirationOf(addr) === freshestExp); + } + + // Apply new addresses if changed + if (addresses.length !== newAddresses.length) { + await node.set({ commissioning: { addresses } }); + } + } + } +} + +class Factory extends ClientNodeFactory { + #owner: ClientNodes; + + constructor(owner: ClientNodes) { + super(); + this.#owner = owner; + } + + create(options: ClientNode.Options) { + if (options.id === undefined) { + options.id = this.#owner.owner.env.get(ServerNodeStore).clientStores.allocateId(); + } + const node = new ClientNode({ + ...options, + owner: this.#owner.owner, + }); + node.construction.start(); + return node; + } + + find(descriptor: RemoteDescriptor) { + for (const node of this.#owner) { + if (RemoteDescriptor.is(node.state.commissioning, descriptor)) { + return node; + } + } + } + + get nodes() { + return this.#owner; + } +} + +function expirationOf>( + lifespan: T, +): T extends { discoveredAt: number } ? number : number | undefined { + if (lifespan.discoveredAt !== undefined) { + return lifespan.discoveredAt + (lifespan.ttl ?? DEFAULT_TTL); + } + return undefined as unknown as number; +} diff --git a/packages/node/src/node/client/NodePeerStore.ts b/packages/node/src/node/client/NodePeerStore.ts new file mode 100644 index 0000000000..ed57a5b32f --- /dev/null +++ b/packages/node/src/node/client/NodePeerStore.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RemoteDescriptor } from "#behavior/system/commissioning/RemoteDescriptor.js"; +import { InternalError } from "#general"; +import { ServerNode } from "#node/ServerNode.js"; +import { OperationalPeer, PeerAddress, PeerAddressStore, PeerDataStore } from "#protocol"; + +/** + * This is an adapter for lower-level components in the protocol package. + */ +export class NodePeerStore extends PeerAddressStore { + #owner: ServerNode; + + constructor(owner: ServerNode) { + super(); + this.#owner = owner; + } + + async loadPeers(): Promise { + return [...this.#owner.nodes] + .map(node => { + const commissioning = node.state.commissioning; + if (!commissioning.peerAddress) { + return; + } + return { + address: commissioning.peerAddress, + operationalAddress: commissioning.addresses?.find(addr => addr.type === "udp"), + discoveryData: RemoteDescriptor.fromLongForm(commissioning), + }; + }) + .filter(addr => addr !== undefined); + } + + async updatePeer(peer: OperationalPeer) { + const node = this.#owner.nodes.get(peer.address); + if (!node) { + return; + } + + await node.act(agent => { + const state = agent.commissioning.state; + RemoteDescriptor.toLongForm(peer.discoveryData, state); + if (peer.operationalAddress) { + // TODO - modify lower tiers to pass along full set of operational addresses + state.addresses = [peer.operationalAddress]; + } + }); + } + + async deletePeer(address: PeerAddress) { + // TODO - should we be doing this separately? + const node = this.#owner.nodes.get(address); + if (node) { + await node.close(); + } + } + + async createNodeStore(): Promise { + throw new InternalError("Node store creation not supported"); + } +} diff --git a/packages/node/src/node/client/index.ts b/packages/node/src/node/client/index.ts index 217393a7fc..6beca241bf 100644 --- a/packages/node/src/node/client/index.ts +++ b/packages/node/src/node/client/index.ts @@ -4,4 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from "./ClientEndpointInitializer.js"; +export * from "./ClientNodes.js"; +export * from "./NodePeerStore.js"; export * from "./storage/index.js"; diff --git a/packages/node/src/node/server/ServerEndpointInitializer.ts b/packages/node/src/node/server/ServerEndpointInitializer.ts index 64aec48402..b77030fb19 100644 --- a/packages/node/src/node/server/ServerEndpointInitializer.ts +++ b/packages/node/src/node/server/ServerEndpointInitializer.ts @@ -23,7 +23,7 @@ export class ServerEndpointInitializer extends EndpointInitializer { this.#store = environment.get(ServerNodeStore); } - override initializeDescendent(endpoint: Endpoint) { + override initializeDescendant(endpoint: Endpoint) { if (!endpoint.lifecycle.hasId) { endpoint.id = this.#identifyPart(endpoint); } @@ -33,6 +33,15 @@ export class ServerEndpointInitializer extends EndpointInitializer { endpoint.behaviors.require(DescriptorServer); } + override async eraseDescendant(endpoint: Endpoint) { + if (!endpoint.lifecycle.hasId) { + return; + } + + const store = this.#store.endpointStores.storeForEndpoint(endpoint); + await store.erase(); + } + /** * If a {@link Endpoint} does not yet have a {@link EndpointServer}, create one now, then create a * {@link BehaviorBacking} for a specific {@link Behavior}. diff --git a/packages/node/src/node/storage/ClientNodeStore.ts b/packages/node/src/node/storage/ClientNodeStore.ts deleted file mode 100644 index eeeefbb336..0000000000 --- a/packages/node/src/node/storage/ClientNodeStore.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @license - * Copyright 2022-2024 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Logger, StorageContext, StorageContextFactory } from "#general"; -import { PeerAddress } from "#protocol"; -import { FabricIndex, NodeId } from "#types"; -import { NodeStore } from "./NodeStore.js"; - -const logger = Logger.get("ClientNodeStore"); - -/** - * A "client" node store is a node store with operational information detailing how to connect to a client. - */ -export class ClientNodeStore extends NodeStore { - #address: PeerAddress; - #discoveryStorage?: StorageContext; - - constructor(address: PeerAddress, storage: StorageContextFactory) { - super(storage); - this.#address = address; - this.construction.start(); - } - - get address() { - return this.#address; - } - - /** - * Load a store for each peer that was previously persisted. - */ - static async load(peerStorage: StorageContext) { - const addresses = await peerStorage.contexts(); - const stores = Array(); - - for (const addrStr of addresses) { - const addrComponents = addrStr.split("-"); - const fabricIndex = FabricIndex(Number.parseInt(addrComponents[0])); - const nodeId = NodeId(Number.parseInt(addrComponents[1], 16)); - - if (addrComponents.length !== 2 || Number.isNaN(fabricIndex) || Number.isNaN(nodeId)) { - logger.warn(`Ignoring peer storage context "${addrStr}" due to invalid name format`); - continue; - } - - const addr = PeerAddress({ fabricIndex, nodeId }); - - stores.push(new ClientNodeStore(addr, peerStorage.createContext(addrStr))); - } - - return stores; - } - - /** - * Add a store for a new (previously unknown) peer. - */ - static async create(address: PeerAddress, peerStorage: StorageContext) { - const addrStr = `${address.fabricIndex}-${address.nodeId.toString(16)}`; - const store = new ClientNodeStore(address, peerStorage.createContext(addrStr)); - - // Ensure we don't accidentally pick up stale information for the same address - await store.erase(); - } - - get discoveryStorage() { - if (!this.#discoveryStorage) { - this.#discoveryStorage = this.factory.createContext("discovery"); - } - return this.#discoveryStorage; - } - - override async erase() { - await this.discoveryStorage.clearAll(); - await super.erase(); - } -} diff --git a/packages/node/src/node/storage/ClientStoreService.ts b/packages/node/src/node/storage/ClientStoreService.ts new file mode 100644 index 0000000000..510f2ee54b --- /dev/null +++ b/packages/node/src/node/storage/ClientStoreService.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Construction, MatterAggregateError, StorageContext } from "#general"; +import type { ClientNode } from "#node/ClientNode.js"; +import type { Node } from "#node/Node.js"; +import { NodeStore } from "./NodeStore.js"; + +const CLIENT_ID_PREFIX = "peer"; + +/** + * Manages all {@link ClientNodeStore}s for a {@link Node}. + * + * We eagerly load all available endpoint data from disk because this allows us to keep {@link Endpoint} initialization + * more synchronous. We can initialize most behaviors synchronously if their state is already in memory. + * + * TODO - cleanup of storage for permanently removed endpoints + */ +export abstract class ClientStoreService { + /** + * Allocate a stable local ID. for a peer + * + * The ID may be preassigned or we will assign using an incrementing sequential number. The number is reserved for + * the life of this process or, if data is persisted, until erased. + */ + abstract allocateId(): string; + + /** + * Obtain the store for a single {@link ClientNode}. + * + * These stores are cached internally by ID. + */ + abstract storeForNode(node: ClientNode): NodeStore; + + /** + * List all nodes present. + */ + abstract knownIds: string[]; +} + +export class ClientStoreFactory extends ClientStoreService { + #storage: StorageContext; + #stores = {} as Record; + #construction: Construction; + #nextAutomaticId = 1; + + get construction() { + return this.#construction; + } + + constructor(storage: StorageContext) { + super(); + this.#storage = storage; + this.#construction = Construction(this); + this.#construction.start(); + } + + async [Construction.construct]() { + const contexts = await this.#storage.contexts(); + + for (const id of contexts) { + if (!id.startsWith(CLIENT_ID_PREFIX)) { + continue; + } + + const num = Number.parseInt(id.slice(CLIENT_ID_PREFIX.length)); + if (!Number.isNaN(num)) { + if (this.#nextAutomaticId <= num) { + this.#nextAutomaticId = num + 1; + } + } + + const store = new NodeStore(this.#storage.createContext(id)); + this.#stores[id] = store; + store.construction.start(); + } + + const results = await Promise.allSettled(Object.values(this.#stores).map(store => store.construction.ready)); + const errors = results.filter(result => result.status === "rejected").map(result => result.reason); + + if (errors.length) { + throw new MatterAggregateError(errors, "Error loading one or more client stores"); + } + } + + allocateId() { + this.#construction.assert(); + + return `${CLIENT_ID_PREFIX}${this.#nextAutomaticId++}`; + } + + storeForNode(node: ClientNode): NodeStore { + this.#construction.assert(); + + let store = this.#stores[node.id]; + if (store) { + return store; + } + + store = new NodeStore(this.#storage.createContext(node.id)); + store.construction.start(); + this.#stores[node.id] = store; + + return store; + } + + get knownIds() { + this.#construction.assert(); + + return Object.keys(this.#stores); + } + + async close() { + await this.construction; + } +} diff --git a/packages/node/src/node/storage/EndpointStoreService.ts b/packages/node/src/node/storage/EndpointStoreService.ts index 3803f6c0f2..89e490861b 100644 --- a/packages/node/src/node/storage/EndpointStoreService.ts +++ b/packages/node/src/node/storage/EndpointStoreService.ts @@ -160,21 +160,17 @@ export class EndpointStoreFactory extends EndpointStoreService { storeForEndpoint(endpoint: Endpoint): EndpointStore { this.#construction.assert(); - if (!endpoint.lifecycle.hasId) { - throw new InternalError("Endpoint storage access without assigned ID"); + if (endpoint.maybeNumber === 0) { + return this.#construction.assert("root node store", this.#root); } - if (endpoint.owner) { - return this.storeForEndpoint(endpoint.owner).childStoreFor(endpoint); - } - if (endpoint.number !== 0) { + + if (!endpoint.owner) { throw new InternalError( "Endpoint storage inaccessible because endpoint is not a node and is not owned by another endpoint", ); } - if (!this.#root) { - throw new InternalError("Endpoint storage accessed prior to initialization"); - } - return this.#root; + + return this.storeForEndpoint(endpoint.owner).childStoreFor(endpoint); } /** diff --git a/packages/node/src/node/storage/ServerNodeStore.ts b/packages/node/src/node/storage/ServerNodeStore.ts index 486a5019a3..816dad9e94 100644 --- a/packages/node/src/node/storage/ServerNodeStore.ts +++ b/packages/node/src/node/storage/ServerNodeStore.ts @@ -11,12 +11,10 @@ import { Environment, ImplementationError, Logger, - StorageContext, StorageManager, StorageService, } from "#general"; -import { PeerAddress } from "#protocol"; -import { ClientNodeStore } from "./ClientNodeStore.js"; +import { ClientStoreFactory, ClientStoreService } from "./ClientStoreService.js"; import { NodeStore } from "./NodeStore.js"; const logger = Logger.get("ServerNodeStore"); @@ -30,7 +28,7 @@ export class ServerNodeStore extends NodeStore implements Destructable { #nodeId: string; #location: string; #storageManager?: StorageManager; - #peerStorage?: StorageContext; + #clientStores?: ClientStoreFactory; constructor(environment: Environment, nodeId: string) { super({ @@ -55,26 +53,16 @@ export class ServerNodeStore extends NodeStore implements Destructable { return await asyncNew(this, environment, nodeId); } - allPeerStores() { - return ClientNodeStore.load(this.#peers); - } - - addPeerStore(address: PeerAddress) { - return ClientNodeStore.create(address, this.#peers); - } - async close() { await this.construction.close(async () => { + await this.#clientStores?.close(); await this.#storageManager?.close(); this.#logChange("Closed"); }); } - get #peers() { - if (!this.#peerStorage) { - this.#peerStorage = this.factory.createContext("peer"); - } - return this.#peerStorage; + get clientStores(): ClientStoreService { + return this.construction.assert("client stores", this.#clientStores); } #logChange(what: "Opened" | "Closed") { @@ -85,6 +73,8 @@ export class ServerNodeStore extends NodeStore implements Destructable { this.#storageManager = await this.#env.get(StorageService).open(this.#nodeId); this.#env.set(StorageManager, this.#storageManager); + this.#clientStores = await asyncNew(ClientStoreFactory, this.#storageManager.createContext("nodes")); + await super.initializeStorage(); this.#logChange("Opened"); diff --git a/packages/node/src/node/storage/index.ts b/packages/node/src/node/storage/index.ts index f8039d9d4e..24d2fbab68 100644 --- a/packages/node/src/node/storage/index.ts +++ b/packages/node/src/node/storage/index.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from "./ClientNodeStore.js"; +export * from "./ClientStoreService.js"; export * from "./EndpointStoreService.js"; export * from "./NodeStore.js"; export * from "./ServerNodeStore.js"; diff --git a/packages/node/test/node/ServerNodeTest.ts b/packages/node/test/node/ServerNodeTest.ts index 8c58a3ce16..6eb810de02 100644 --- a/packages/node/test/node/ServerNodeTest.ts +++ b/packages/node/test/node/ServerNodeTest.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CommissioningBehavior } from "#behavior/system/commissioning/CommissioningBehavior.js"; +import { CommissioningServer } from "#behavior/system/commissioning/CommissioningServer.js"; import { BasicInformationBehavior } from "#behaviors/basic-information"; import { DescriptorBehavior } from "#behaviors/descriptor"; import { PumpConfigurationAndControlServer } from "#behaviors/pump-configuration-and-control"; @@ -341,7 +341,7 @@ describe("ServerNode", () => { await node.cancel(); } - await node.factoryReset(); + await node.erase(); // Confirm previous online state is resumed expect(node.lifecycle.isOnline).equals(mode === "online"); @@ -350,7 +350,7 @@ describe("ServerNode", () => { expect(node.stateOf(BasicInformationBehavior).vendorName).equals("Matter.js Test Vendor"); // Confirm pairing codes are available - const pairingCodes = node.stateOf(CommissioningBehavior).pairingCodes; + const pairingCodes = node.stateOf(CommissioningServer).pairingCodes; expect(typeof pairingCodes).equals("object"); expect(typeof pairingCodes.manualPairingCode).equals("string"); @@ -552,7 +552,7 @@ async function commission(existingNode?: MockServerNode, number = 0) { const { node } = await almostCommission(existingNode, number); // Do not reuse session from initial commissioning because we must now move from CASE to PASE - const fabric = node.env.get(FabricManager).getFabrics()[number]; + const fabric = node.env.get(FabricManager).fabrics[number]; const contextOptions = { exchange: await node.createExchange({ fabric, diff --git a/packages/node/test/node/mock-node.ts b/packages/node/test/node/mock-node.ts index 33d9213f70..9bd2973aef 100644 --- a/packages/node/test/node/mock-node.ts +++ b/packages/node/test/node/mock-node.ts @@ -21,7 +21,8 @@ import { EndpointNumber } from "#types"; export class MockPartInitializer extends EndpointInitializer { #nextId = 1; - override initializeDescendent(endpoint: Endpoint) { + + override initializeDescendant(endpoint: Endpoint) { if (!endpoint.lifecycle.hasNumber) { endpoint.number = EndpointNumber(this.#nextId++); } @@ -30,6 +31,8 @@ export class MockPartInitializer extends EndpointInitializer { } } + async eraseDescendant(_endpoint: Endpoint) {} + createBacking(endpoint: Endpoint, behavior: Behavior.Type) { return new ServerBehaviorBacking(endpoint, behavior); } diff --git a/packages/nodejs-ble/src/BleScanner.ts b/packages/nodejs-ble/src/BleScanner.ts index 89f08b0fa3..4ce290e9ea 100644 --- a/packages/nodejs-ble/src/BleScanner.ts +++ b/packages/nodejs-ble/src/BleScanner.ts @@ -33,7 +33,7 @@ export class BleScanner implements Scanner { string, { resolver: () => void; - timer: Timer; + timer?: Timer; resolveOnUpdatedRecords: boolean; } >(); @@ -57,11 +57,14 @@ export class BleScanner implements Scanner { * Registers a deferred promise for a specific queryId together with a timeout and return the promise. * The promise will be resolved when the timer runs out latest. */ - private async registerWaiterPromise(queryId: string, timeoutSeconds: number, resolveOnUpdatedRecords = true) { + private async registerWaiterPromise(queryId: string, timeoutSeconds?: number, resolveOnUpdatedRecords = true) { const { promise, resolver } = createPromise(); - const timer = Time.getTimer("BLE query timeout", timeoutSeconds * 1000, () => - this.finishWaiter(queryId, true), - ).start(); + let timer; + if (timeoutSeconds !== undefined) { + timer = Time.getTimer("BLE query timeout", timeoutSeconds * 1000, () => + this.finishWaiter(queryId, true), + ).start(); + } this.recordWaiters.set(queryId, { resolver, timer, resolveOnUpdatedRecords }); logger.debug( `Registered waiter for query ${queryId} with timeout ${timeoutSeconds} seconds${ @@ -81,7 +84,7 @@ export class BleScanner implements Scanner { const { timer, resolver, resolveOnUpdatedRecords } = waiter; if (isUpdatedRecord && !resolveOnUpdatedRecords) return; logger.debug(`Finishing waiter for query ${queryId}, resolving: ${resolvePromise}`); - timer.stop(); + timer?.stop(); if (resolvePromise) { resolver(); } @@ -239,15 +242,27 @@ export class BleScanner implements Scanner { async findCommissionableDevicesContinuously( identifier: CommissionableDeviceIdentifiers, callback: (device: CommissionableDevice) => void, - timeoutSeconds = 60, + timeoutSeconds?: number, + cancelSignal?: Promise, ): Promise { const discoveredDevices = new Set(); - const discoveryEndTime = Time.nowMs() + timeoutSeconds * 1000; + const discoveryEndTime = timeoutSeconds ? Time.nowMs() + timeoutSeconds * 1000 : undefined; const queryKey = this.buildCommissionableQueryIdentifier(identifier); await this.nobleClient.startScanning(); - while (true) { + let canceled = false; + cancelSignal?.then( + () => { + canceled = true; + this.finishWaiter(queryKey, true); + }, + cause => { + logger.error("Unexpected error canceling commissioning", cause); + }, + ); + + while (!canceled) { this.getCommissionableDevices(identifier).forEach(({ deviceData }) => { const { deviceIdentifier } = deviceData; if (!discoveredDevices.has(deviceIdentifier)) { @@ -256,11 +271,16 @@ export class BleScanner implements Scanner { } }); - const remainingTime = Math.ceil((discoveryEndTime - Time.nowMs()) / 1000); - if (remainingTime <= 0) { - break; + let remainingTime; + if (discoveryEndTime !== undefined) { + const remainingTime = Math.ceil((discoveryEndTime - Time.nowMs()) / 1000); + if (remainingTime <= 0) { + break; + } } - await this.registerWaiterPromise(queryKey, remainingTime, false); + + const waiter = this.registerWaiterPromise(queryKey, remainingTime, false); + await waiter; } await this.nobleClient.stopScanning(); return this.getCommissionableDevices(identifier).map(({ deviceData }) => deviceData); diff --git a/packages/nodejs/src/environment/NodeJsEnvironment.ts b/packages/nodejs/src/environment/NodeJsEnvironment.ts index 9d56106f62..d109559bf5 100644 --- a/packages/nodejs/src/environment/NodeJsEnvironment.ts +++ b/packages/nodejs/src/environment/NodeJsEnvironment.ts @@ -14,6 +14,7 @@ import { VariableService, } from "#general"; import { existsSync, readFileSync } from "fs"; +import { writeFile } from "fs/promises"; import { resolve } from "path"; import { NodeJsNetwork } from "../net/NodeJsNetwork.js"; import { StorageBackendDiskAsync } from "../storage/StorageBackendDiskAsync.js"; @@ -86,11 +87,21 @@ function loadVariables(env: Environment) { vars.addArgvStyle(process.argv); // Load config files - vars.addConfigStyle(loadConfigFile(vars)); + const { configPath, configVars } = loadConfigFile(vars); + vars.addConfigStyle(configVars); // Reload environment and argv so they override config vars.addUnixEnvStyle(process.env); vars.addArgvStyle(process.argv); + + // Enable persistent configuration values + vars.persistConfigValue = async (name: string, value: VariableService.Value) => { + if (value === undefined) { + delete configVars[name]; + } + configVars[name] = value; + await writeFile(configPath, JSON.stringify(configVars, undefined, 4)); + }; } function configureRuntime(env: Environment) { @@ -114,27 +125,27 @@ function configureNetwork(env: Environment) { } export function loadConfigFile(vars: VariableService) { - const path = vars.get("path.config", "config.json"); + const configPath = vars.get("path.config", "config.json"); - if (!existsSync(path)) { - return {}; + if (!existsSync(configPath)) { + return { configPath, configVars: {} }; } let configJson; try { - configJson = readFileSync(path).toString(); + configJson = readFileSync(configPath).toString(); } catch (e) { - throw new ImplementationError(`Error reading configuration file ${path}: ${(e as Error).message}`); + throw new ImplementationError(`Error reading configuration file ${configPath}: ${(e as Error).message}`); } let configVars; try { configVars = JSON.parse(configJson); } catch (e) { - throw new ImplementationError(`Error parsing configuration file ${path}: ${(e as Error).message}`); + throw new ImplementationError(`Error parsing configuration file ${configPath}: ${(e as Error).message}`); } - return configVars; + return { configPath, configVars }; } function getDefaultRoot(envName: string) { diff --git a/packages/nodejs/src/environment/ProcessManager.ts b/packages/nodejs/src/environment/ProcessManager.ts index 68d50c43c1..375b54b698 100644 --- a/packages/nodejs/src/environment/ProcessManager.ts +++ b/packages/nodejs/src/environment/ProcessManager.ts @@ -102,8 +102,9 @@ export class ProcessManager implements Destructable { }; protected interruptHandler = (signal: string) => { - process.off(signal, this.interruptHandler); - this.runtime.cancel(); + if (this.runtime.interrupt()) { + process.on(signal, this.interruptHandler); + } }; protected exitHandler = () => { diff --git a/packages/nodejs/test/IntegrationTest.ts b/packages/nodejs/test/IntegrationTest.ts index 3607312c3e..d57ccca972 100644 --- a/packages/nodejs/test/IntegrationTest.ts +++ b/packages/nodejs/test/IntegrationTest.ts @@ -1216,8 +1216,10 @@ describe("Integration Test", () => { assert.equal(nodeData.length, 1); // Remove variable fields before compare - expect(nodeData[0][1].discoveryData.expires).to.be.an("number"); - delete nodeData[0][1].discoveryData.expires; + expect(nodeData[0][1].discoveryData.discoveredAt).to.be.an("number"); + delete nodeData[0][1].discoveryData.discoveredAt; + expect(nodeData[0][1].discoveryData.ttl).to.be.an("number"); + delete nodeData[0][1].discoveryData.ttl; expect(nodeData[0][1].discoveryData.deviceIdentifier).to.be.an("string"); delete nodeData[0][1].discoveryData.deviceIdentifier; expect(nodeData[0][1].deviceData.basicInformation.serialNumber).to.be.an("string"); @@ -1486,8 +1488,10 @@ describe("Integration Test", () => { }); // Remove variable fields before compare - expect(nodeData[1][1].discoveryData.expires).to.be.an("number"); - delete nodeData[1][1].discoveryData.expires; + expect(nodeData[1][1].discoveryData.discoveredAt).to.be.an("number"); + delete nodeData[1][1].discoveryData.discoveredAt; + expect(nodeData[1][1].discoveryData.ttl).to.be.an("number"); + delete nodeData[1][1].discoveryData.ttl; expect(nodeData[1][1].discoveryData.deviceIdentifier).to.be.an("string"); delete nodeData[1][1].discoveryData.deviceIdentifier; expect(nodeData[1][1].deviceData.basicInformation.serialNumber).to.be.an("string"); @@ -1860,8 +1864,10 @@ describe("Integration Test", () => { assert.equal(nodeData2.length, 1); // Remove variable fields before compare - expect(nodeData2[0][1].discoveryData.expires).to.be.an("number"); - delete nodeData2[0][1].discoveryData.expires; + expect(nodeData2[0][1].discoveryData.discoveredAt).to.be.an("number"); + delete nodeData2[0][1].discoveryData.discoveredAt; + expect(nodeData2[0][1].discoveryData.ttl).to.be.an("number"); + delete nodeData2[0][1].discoveryData.ttl; expect(nodeData2[0][1].discoveryData.deviceIdentifier).to.be.an("string"); delete nodeData2[0][1].discoveryData.deviceIdentifier; expect(nodeData2[0][1].deviceData.basicInformation.serialNumber).to.be.an("string"); diff --git a/packages/nodejs/test/fabric/FabricManagerTest.ts b/packages/nodejs/test/fabric/FabricManagerTest.ts index ff852405cb..7c304cf74f 100644 --- a/packages/nodejs/test/fabric/FabricManagerTest.ts +++ b/packages/nodejs/test/fabric/FabricManagerTest.ts @@ -28,7 +28,7 @@ describe("FabricManager", () => { const fabric = await buildFabric(); fabricManager.addFabric(fabric); - assert.deepEqual(fabricManager.getFabrics(), [fabric]); + assert.deepEqual(fabricManager.fabrics, [fabric]); assert.deepEqual(storage.get(["Context"], "fabrics"), undefined); }); @@ -54,7 +54,7 @@ describe("FabricManager", () => { fabricManager.addFabric(fabric); await fabricManager.persistFabrics(); - assert.deepEqual(fabricManager.getFabrics(), [fabric]); + assert.deepEqual(fabricManager.fabrics, [fabric]); assert.deepEqual(storage.get(["Context"], "fabrics"), [fabric.config]); }); @@ -65,8 +65,8 @@ describe("FabricManager", () => { fabricManager = new FabricManager(storageManager.createContext("Context")); await fabricManager.construction.ready; - assert.equal(fabricManager.getFabrics().length, 1); - assert.deepEqual(fabricManager.getFabrics()[0].config, fabric.config); + assert.equal(fabricManager.fabrics.length, 1); + assert.deepEqual(fabricManager.fabrics[0].config, fabric.config); }); }); @@ -76,7 +76,7 @@ describe("FabricManager", () => { fabricManager.addFabric(fabric); await fabricManager.removeFabric(fabric.fabricIndex); - assert.deepEqual(fabricManager.getFabrics(), []); + assert.deepEqual(fabricManager.fabrics, []); }); it("throws when removing a non-existent fabric", async () => { diff --git a/packages/protocol/src/common/FailsafeContext.ts b/packages/protocol/src/common/FailsafeContext.ts index c8bb716601..13e8610168 100644 --- a/packages/protocol/src/common/FailsafeContext.ts +++ b/packages/protocol/src/common/FailsafeContext.ts @@ -227,7 +227,7 @@ export abstract class FailsafeContext { } builder.setOperationalCert(nocValue, icacValue); - const fabricAlreadyExisting = this.#fabrics.getFabrics().find(fabric => builder.matchesToFabric(fabric)); + const fabricAlreadyExisting = this.#fabrics.find(fabric => builder.matchesToFabric(fabric)); if (fabricAlreadyExisting) { throw new MatterFabricConflictError( @@ -264,7 +264,7 @@ export abstract class FailsafeContext { let fabric: Fabric | undefined = undefined; if (this.fabricIndex !== undefined) { const fabricIndex = this.fabricIndex; - fabric = this.#fabrics.getFabrics().find(fabric => fabric.fabricIndex === fabricIndex); + fabric = this.#fabrics.for(fabricIndex); if (fabric !== undefined) { const session = this.#sessions.getSessionForNode(fabric.addressOf(fabric.rootNodeId)); if (session !== undefined && session.isSecure) { @@ -290,7 +290,7 @@ export abstract class FailsafeContext { if (!this.#forUpdateNoc && fabric !== undefined) { const fabricIndex = this.fabricIndex; if (fabricIndex !== undefined) { - const fabric = this.#fabrics.getFabrics().find(fabric => fabric.fabricIndex === fabricIndex); + const fabric = this.#fabrics.for(fabricIndex); if (fabric !== undefined) { await this.revokeFabric(fabric); } diff --git a/packages/protocol/src/common/InstanceBroadcaster.ts b/packages/protocol/src/common/InstanceBroadcaster.ts index 15e91a3ad3..1a3b346b31 100644 --- a/packages/protocol/src/common/InstanceBroadcaster.ts +++ b/packages/protocol/src/common/InstanceBroadcaster.ts @@ -139,13 +139,13 @@ export interface CommissioningModeInstanceData extends ProductDescription { /** Session Active Interval of the device for commissionable announcements. */ sessionActiveInterval?: number; - /** Duration of time the node should stay Active after the last network activity. **/ + /** Duration of time the node should stay active after the last network activity. **/ sessionActiveThreshold?: number; - /** Pairing Hint of the device for commissionable announcements. */ + /** Pairing hint of the device for commissionable announcements. */ pairingHint?: TypeFromPartialBitSchema; - /** Pairing Instruction of the device for commissionable announcements. */ + /** Pairing instruction of the device for commissionable announcements. */ pairingInstructions?: string; } diff --git a/packages/protocol/src/common/Scanner.ts b/packages/protocol/src/common/Scanner.ts index 53169f37b0..bc5c566554 100644 --- a/packages/protocol/src/common/Scanner.ts +++ b/packages/protocol/src/common/Scanner.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BasicSet, ChannelType, Environment, Environmental, ServerAddress, ServerAddressIp } from "#general"; +import { BasicSet, ChannelType, Environment, Environmental, Lifespan, ServerAddress, ServerAddressIp } from "#general"; import { DiscoveryCapabilitiesBitmap, NodeId, TypeFromPartialBitSchema, VendorId } from "#types"; import { Fabric } from "../fabric/Fabric.js"; @@ -47,10 +47,11 @@ export type DiscoveryData = { ICD?: number; }; -export type DiscoverableDevice = DiscoveryData & { - /** The device's addresses IP/port pairs */ - addresses: SA[]; -}; +export type DiscoverableDevice = DiscoveryData & + Partial & { + /** The device's addresses IP/port pairs */ + addresses: SA[]; + }; export type AddressTypeFromDevice> = D extends DiscoverableDevice ? SA : never; @@ -103,7 +104,6 @@ export type CommissionableDeviceIdentifiers = } | { /** Pass empty object to discover any commissionable device. */ - [K in any]: never; // aka "empty object" for just discovering any commisionable device }; export interface Scanner { @@ -145,6 +145,7 @@ export interface Scanner { identifier: CommissionableDeviceIdentifiers, callback: (device: CommissionableDevice) => void, timeoutSeconds?: number, + cancelSignal?: Promise, ): Promise; /** Return already discovered commissionable devices and return them. Does not send out new DNS-SD queries. */ diff --git a/packages/protocol/src/fabric/FabricAuthority.ts b/packages/protocol/src/fabric/FabricAuthority.ts index b7bac354da..1d5e1d1c77 100644 --- a/packages/protocol/src/fabric/FabricAuthority.ts +++ b/packages/protocol/src/fabric/FabricAuthority.ts @@ -15,7 +15,7 @@ import { Logger, } from "#general"; import { CaseAuthenticatedTag, FabricId, FabricIndex, NodeId, VendorId } from "#types"; -import { FabricBuilder } from "./Fabric.js"; +import { Fabric, FabricBuilder } from "./Fabric.js"; import { FabricManager } from "./FabricManager.js"; const logger = Logger.get("FabricAuthority"); @@ -79,11 +79,14 @@ export class FabricAuthority { * List all controlled fabrics. */ get fabrics() { - return Array.from(this.#fabrics).filter(fabric => { - if (Bytes.areEqual(fabric.rootCert, this.#ca.rootCert)) { - return true; - } - }); + return Array.from(this.#fabrics).filter(this.hasControlOf.bind(this)); + } + + /** + * Determine whether a fabric belongs to this authority. + */ + hasControlOf(fabric: Fabric) { + return Bytes.areEqual(fabric.rootCert, this.#ca.rootCert); } /** diff --git a/packages/protocol/src/fabric/FabricManager.ts b/packages/protocol/src/fabric/FabricManager.ts index 911419ac0f..eb8395d3e9 100644 --- a/packages/protocol/src/fabric/FabricManager.ts +++ b/packages/protocol/src/fabric/FabricManager.ts @@ -180,15 +180,29 @@ export class FabricManager { } [Symbol.iterator]() { + this.#construction.assert(); + return this.#fabrics.values(); } - getFabrics() { + get fabrics() { this.#construction.assert(); return Array.from(this.#fabrics.values()); } + get length() { + return this.fabrics.length; + } + + find(predicate: (fabric: Fabric) => boolean) { + return this.fabrics.find(predicate); + } + + map(translator: (fabric: Fabric) => T) { + return this.fabrics.map(translator); + } + findFabricFromDestinationId(destinationId: Uint8Array, initiatorRandom: Uint8Array) { this.#construction.assert(); diff --git a/packages/protocol/src/mdns/MdnsScanner.ts b/packages/protocol/src/mdns/MdnsScanner.ts index eccb1cdf81..1e69efd959 100644 --- a/packages/protocol/src/mdns/MdnsScanner.ts +++ b/packages/protocol/src/mdns/MdnsScanner.ts @@ -16,6 +16,7 @@ import { DnsRecordClass, DnsRecordType, ImplementationError, + Lifespan, Logger, MAX_MDNS_MESSAGE_SIZE, Network, @@ -52,23 +53,21 @@ import { MDNS_BROADCAST_IPV4, MDNS_BROADCAST_IPV6, MDNS_BROADCAST_PORT } from ". const logger = Logger.get("MdnsScanner"); -type MatterServerRecordWithExpire = ServerAddressIp & { - expires: number; -}; +type MatterServerRecordWithExpire = ServerAddressIp & Lifespan; -type CommissionableDeviceRecordWithExpire = Omit & { - expires: number; - addresses: Map; // Override addresses type to include expiration - instanceId: string; // instance ID - SD: number; // Additional Field for Short discriminator - V?: number; // Additional Field for Vendor ID - P?: number; // Additional Field for Product ID -}; +type CommissionableDeviceRecordWithExpire = Omit & + Lifespan & { + addresses: Map; // Override addresses type to include expiration + instanceId: string; // instance ID + SD: number; // Additional Field for Short discriminator + V?: number; // Additional Field for Vendor ID + P?: number; // Additional Field for Product ID + }; -type OperationalDeviceRecordWithExpire = Omit & { - expires: number; - addresses: Map; // Override addresses type to include expiration -}; +type OperationalDeviceRecordWithExpire = Omit & + Lifespan & { + addresses: Map; // Override addresses type to include expiration + }; /** The initial number of seconds between two announcements. MDNS specs require 1-2 seconds, so lets use the middle. */ const START_ANNOUNCE_INTERVAL_SECONDS = 1.5; @@ -322,7 +321,7 @@ export class MdnsScanner implements Scanner { timeoutSeconds !== undefined ? `timeout ${timeoutSeconds} seconds` : "no timeout" }${resolveOnUpdatedRecords ? "" : " (not resolving on updated records)"}`, ); - return { promise }; + await promise; } /** @@ -371,7 +370,7 @@ export class MdnsScanner implements Scanner { let storedDevice = ignoreExistingRecords ? undefined : this.#getOperationalDeviceRecords(deviceMatterQname); if (storedDevice === undefined) { - const { promise } = await this.#registerWaiterPromise(deviceMatterQname, timeoutSeconds); + const promise = this.#registerWaiterPromise(deviceMatterQname, timeoutSeconds); this.#setQueryRecords(deviceMatterQname, [ { @@ -450,24 +449,35 @@ export class MdnsScanner implements Scanner { #buildCommissionableQueryIdentifier(identifier: CommissionableDeviceIdentifiers) { if ("instanceId" in identifier) { return getDeviceInstanceQname(identifier.instanceId); - } else if ("longDiscriminator" in identifier) { + } + + if ("longDiscriminator" in identifier) { return getLongDiscriminatorQname(identifier.longDiscriminator); - } else if ("shortDiscriminator" in identifier) { + } + + if ("shortDiscriminator" in identifier) { return getShortDiscriminatorQname(identifier.shortDiscriminator); - } else if ("vendorId" in identifier && "productId" in identifier) { + } + + if ("vendorId" in identifier && "productId" in identifier) { // Custom identifier because normally productId is only included in TXT record return `_VP${identifier.vendorId}+${identifier.productId}`; - } else if ("vendorId" in identifier) { + } + + if ("vendorId" in identifier) { return getVendorQname(identifier.vendorId); - } else if ("deviceType" in identifier) { + } + + if ("deviceType" in identifier) { return getDeviceTypeQname(identifier.deviceType); - } else if ("productId" in identifier) { + } + + if ("productId" in identifier) { // Custom identifier because normally productId is only included in TXT record return `_P${identifier.productId}`; - } else if (Object.keys(identifier).length === 0) { - return getCommissioningModeQname(); } - throw new ImplementationError(`Invalid commissionable device identifier : ${Logger.toJSON(identifier)}`); // Should neven happen + + return getCommissioningModeQname(); } #extractInstanceId(instanceName: string) { @@ -582,7 +592,7 @@ export class MdnsScanner implements Scanner { : this.#getCommissionableDeviceRecords(identifier).filter(({ addresses }) => addresses.length > 0); if (storedRecords.length === 0) { const queryId = this.#buildCommissionableQueryIdentifier(identifier); - const { promise } = await this.#registerWaiterPromise(queryId, timeoutSeconds); + const promise = this.#registerWaiterPromise(queryId, timeoutSeconds); this.#setQueryRecords(queryId, this.#getCommissionableQueryRecords(identifier)); @@ -603,15 +613,27 @@ export class MdnsScanner implements Scanner { async findCommissionableDevicesContinuously( identifier: CommissionableDeviceIdentifiers, callback: (device: CommissionableDevice) => void, - timeoutSeconds = 900, + timeoutSeconds?: number, + cancelSignal?: Promise, ): Promise { const discoveredDevices = new Set(); - const discoveryEndTime = Time.nowMs() + timeoutSeconds * 1000; + const discoveryEndTime = timeoutSeconds ? Time.nowMs() + timeoutSeconds * 1000 : undefined; const queryId = this.#buildCommissionableQueryIdentifier(identifier); this.#setQueryRecords(queryId, this.#getCommissionableQueryRecords(identifier)); - while (true) { + let canceled = false; + cancelSignal?.then( + () => { + canceled = true; + this.#finishWaiter(queryId, true); + }, + cause => { + logger.error("Unexpected error canceling commissioning", cause); + }, + ); + + while (!canceled) { this.#getCommissionableDeviceRecords(identifier).forEach(device => { const { deviceIdentifier } = device; if (!discoveredDevices.has(deviceIdentifier)) { @@ -620,12 +642,14 @@ export class MdnsScanner implements Scanner { } }); - const remainingTime = Math.ceil((discoveryEndTime - Time.nowMs()) / 1000); - if (remainingTime <= 0) { - break; + let remainingTime; + if (discoveryEndTime !== undefined) { + const remainingTime = Math.ceil((discoveryEndTime - Time.nowMs()) / 1000); + if (remainingTime <= 0) { + break; + } } - const { promise } = await this.#registerWaiterPromise(queryId, remainingTime, false); - await promise; + await this.#registerWaiterPromise(queryId, remainingTime, false); } return this.#getCommissionableDeviceRecords(identifier); } @@ -731,7 +755,8 @@ export class MdnsScanner implements Scanner { if (device !== undefined) { device = { ...device, - expires: Time.nowMs() + ttl * 1000, + discoveredAt: Time.nowMs(), + ttl: ttl * 1000, ...txtData, }; } else { @@ -742,7 +767,8 @@ export class MdnsScanner implements Scanner { device = { deviceIdentifier: matterName, addresses: new Map(), - expires: Time.nowMs() + ttl * 1000, + discoveredAt: Time.nowMs(), + ttl: ttl * 1000, ...txtData, }; } @@ -778,7 +804,8 @@ export class MdnsScanner implements Scanner { const device = this.#operationalDeviceRecords.get(matterName) ?? { deviceIdentifier: matterName, addresses: new Map(), - expires: Time.nowMs() + ttl * 1000, + discoveredAt: Time.nowMs(), + ttl: ttl * 1000, }; const { addresses } = device; if (ips.length > 0) { @@ -790,8 +817,8 @@ export class MdnsScanner implements Scanner { addresses.delete(ip); continue; } - const matterServer = addresses.get(ip) ?? { ip, port, type: "udp", expires: 0 }; - matterServer.expires = Time.nowMs() + ttl * 1000; + const matterServer = addresses.get(ip) ?? ({ ip, port, type: "udp" } as MatterServerRecordWithExpire); + matterServer.discoveredAt = Time.nowMs() + ttl * 1000; addresses.set(matterServer.ip, matterServer); } @@ -898,8 +925,10 @@ export class MdnsScanner implements Scanner { storedRecord.addresses.delete(ip); continue; } - const matterServer = storedRecord.addresses.get(ip) ?? { ip, port, type: "udp", expires: 0 }; - matterServer.expires = Time.nowMs() + ttl * 1000; + const matterServer = + storedRecord.addresses.get(ip) ?? ({ ip, port, type: "udp" } as MatterServerRecordWithExpire); + matterServer.discoveredAt = Time.nowMs(); + matterServer.ttl = ttl * 1000; storedRecord.addresses.set(ip, matterServer); } @@ -989,10 +1018,11 @@ export class MdnsScanner implements Scanner { #expire() { const now = Time.nowMs(); - [...this.#operationalDeviceRecords.entries()].forEach(([recordKey, { addresses, expires }]) => { + [...this.#operationalDeviceRecords.entries()].forEach(([recordKey, { addresses, discoveredAt, ttl }]) => { + const expires = discoveredAt + ttl; if (now < expires) { - [...addresses.entries()].forEach(([key, { expires }]) => { - if (now < expires) return; + [...addresses.entries()].forEach(([key, { discoveredAt, ttl }]) => { + if (now < discoveredAt + ttl) return; addresses.delete(key); }); } @@ -1000,11 +1030,12 @@ export class MdnsScanner implements Scanner { this.#operationalDeviceRecords.delete(recordKey); } }); - [...this.#commissionableDeviceRecords.entries()].forEach(([recordKey, { addresses, expires }]) => { + [...this.#commissionableDeviceRecords.entries()].forEach(([recordKey, { addresses, discoveredAt, ttl }]) => { + const expires = discoveredAt + ttl; if (now < expires) { // Entry still ok but check addresses for expiry - [...addresses.entries()].forEach(([key, { expires }]) => { - if (now < expires) return; + [...addresses.entries()].forEach(([key, { discoveredAt, ttl }]) => { + if (now < discoveredAt + ttl) return; addresses.delete(key); }); } diff --git a/packages/protocol/src/peer/ControllerCommissioner.ts b/packages/protocol/src/peer/ControllerCommissioner.ts index 228fdc064f..8cc8973f56 100644 --- a/packages/protocol/src/peer/ControllerCommissioner.ts +++ b/packages/protocol/src/peer/ControllerCommissioner.ts @@ -18,6 +18,7 @@ import { NetInterfaceSet, NoResponseTimeoutError, ServerAddress, + serverAddressToString, } from "#general"; import { MdnsScanner } from "#mdns/MdnsScanner.js"; import { ControllerCommissioningFlow, ControllerCommissioningFlowOptions } from "#peer/ControllerCommissioningFlow.js"; @@ -34,15 +35,39 @@ import { NodeDiscoveryType, PeerSet } from "./PeerSet.js"; const logger = Logger.get("PeerCommissioner"); /** - * Options needed to commission a new node + * General commissioning options. */ -export interface PeerCommissioningOptions extends Partial { +export interface CommissioningOptions extends Partial { /** The fabric into which to commission. */ fabric: Fabric; /** The node ID to assign (the commissioner assigns a random node ID if omitted) */ nodeId?: NodeId; + /** Passcode to use for commissioning. */ + passcode: number; + + /** + * Commissioning completion callback + * + * This optional callback allows the caller to complete commissioning once PASE commissioning completes. If it does + * not throw, the commissioner considers commissioning complete. + */ + finalizeCommissioning?: (peerAddress: PeerAddress, discoveryData?: DiscoveryData) => Promise; +} + +/** + * Configuration for commissioning a previously discovered node. + */ +export interface LocatedNodeCommissioningOptions extends CommissioningOptions { + addresses: ServerAddress[]; + discoveryData?: DiscoveryData; +} + +/** + * Configuration for performing discovery + commissioning in one step. + */ +export interface DiscoveryAndCommissioningOptions extends CommissioningOptions { /** Discovery related options. */ discovery: ( | { @@ -76,17 +101,6 @@ export interface PeerCommissioningOptions extends Partial Promise; } /** @@ -127,20 +141,41 @@ export class ControllerCommissioner { } /** - * Commission a node. + * Commmission a previously discovered node. + */ + async commission(options: LocatedNodeCommissioningOptions): Promise { + const { passcode, addresses, discoveryData } = options; + + // Prioritize UDP + addresses.sort(a => (a.type === "udp" ? -1 : 1)); + + // Attempt a connection on each known address + let channel: MessageChannel | undefined; + for (const address of addresses) { + try { + channel = await this.#initializePaseSecureChannel(address, passcode, discoveryData); + } catch (e) { + NoResponseTimeoutError.accept(e); + console.warn(`Could not connect to ${serverAddressToString(address)}: ${e.message}`); + } + } + + if (channel === undefined) { + throw new NoResponseTimeoutError("Could not connect to device"); + } + + return await this.#commissionConnectedNode(channel, options, discoveryData); + } + + /** + * Commission a node with discovery. */ - async commission(options: PeerCommissioningOptions): Promise { + async commissionWithDiscovery(options: DiscoveryAndCommissioningOptions): Promise { const { discovery: { timeoutSeconds = 30 }, passcode, } = options; - const commissioningOptions = { - regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.Outdoor, // Set to the most restrictive if relevant - regulatoryCountryCode: "XX", - ...options, - }; - const commissionableDevice = "commissionableDevice" in options.discovery ? options.discovery.commissionableDevice : undefined; let { @@ -215,7 +250,7 @@ export class ControllerCommissioner { paseSecureChannel = result; } - return await this.#commissionDiscoveredNode(paseSecureChannel, commissioningOptions, discoveryData); + return await this.#commissionConnectedNode(paseSecureChannel, options, discoveryData); } /** @@ -226,7 +261,7 @@ export class ControllerCommissioner { async #initializePaseSecureChannel( address: ServerAddress, passcode: number, - device?: CommissionableDevice, + device?: DiscoveryData, ): Promise { let paseChannel: Channel; if (device !== undefined) { @@ -295,12 +330,18 @@ export class ControllerCommissioner { * Method to commission a device with a PASE secure channel. It returns the NodeId of the commissioned device on * success. */ - async #commissionDiscoveredNode( + async #commissionConnectedNode( paseSecureMessageChannel: MessageChannel, - commissioningOptions: PeerCommissioningOptions & ControllerCommissioningFlowOptions, + options: CommissioningOptions, discoveryData?: DiscoveryData, ): Promise { - const { fabric, performCaseCommissioning } = commissioningOptions; + const commissioningOptions = { + regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.Outdoor, // Set to the most restrictive if relevant + regulatoryCountryCode: "XX", + ...options, + }; + + const { fabric, finalizeCommissioning: performCaseCommissioning } = commissioningOptions; // TODO: Create the fabric only when needed before commissioning (to do when refactoring MatterController away) // TODO also move certificateManager and other parts into that class to get rid of them here diff --git a/packages/protocol/src/peer/OperationalPeer.ts b/packages/protocol/src/peer/OperationalPeer.ts index 17efe1451a..b5285dcc8d 100644 --- a/packages/protocol/src/peer/OperationalPeer.ts +++ b/packages/protocol/src/peer/OperationalPeer.ts @@ -7,6 +7,7 @@ import { DiscoveryData } from "#common/Scanner.js"; import { ServerAddressIp } from "#general"; import { PeerDataStore } from "#peer/PeerAddressStore.js"; +import { SessionParameters } from "#session/Session.js"; import { PeerAddress } from "./PeerAddress.js"; /** @@ -25,6 +26,11 @@ export interface OperationalPeer { */ operationalAddress?: ServerAddressIp; + /** + * The peer's session parameters reported during discovery. + */ + sessionParameters?: SessionParameters; + /** * Additional information collected while locating the peer. */ diff --git a/packages/protocol/src/peer/PeerAddress.ts b/packages/protocol/src/peer/PeerAddress.ts index e764a53b51..0d065f814a 100644 --- a/packages/protocol/src/peer/PeerAddress.ts +++ b/packages/protocol/src/peer/PeerAddress.ts @@ -56,6 +56,16 @@ export function PeerAddress(address: PeerAddress): PeerAddress { return internedAddress; } +export namespace PeerAddress { + export function is(addr1?: Readonly, addr2?: Readonly) { + if (addr1 === undefined || addr2 === undefined) { + return false; + } + + return addr1.fabricIndex === addr2.fabricIndex && addr1.nodeId === addr2.nodeId; + } +} + /** * A collection of items keyed by logical address. */ diff --git a/packages/protocol/src/protocol/DeviceAdvertiser.ts b/packages/protocol/src/protocol/DeviceAdvertiser.ts index 000dbcaf5c..cb337aa06a 100644 --- a/packages/protocol/src/protocol/DeviceAdvertiser.ts +++ b/packages/protocol/src/protocol/DeviceAdvertiser.ts @@ -61,7 +61,7 @@ export class DeviceAdvertiser { ); this.#observers.on(this.#context.fabrics.events.deleted, async () => { - if (this.#context.fabrics.getFabrics().length === 0) { + if (this.#context.fabrics.length === 0) { // Last fabric got removed, so expire all announcements await this.#exitOperationalMode(); } @@ -148,7 +148,7 @@ export class DeviceAdvertiser { } } - const fabrics = this.#context.fabrics.getFabrics(); + const fabrics = this.#context.fabrics; if (fabrics.length) { let fabricsWithoutSessions = 0; @@ -160,7 +160,7 @@ export class DeviceAdvertiser { } } for (const broadcaster of this.#broadcasters) { - await broadcaster.setFabrics(fabrics); + await broadcaster.setFabrics(fabrics.fabrics); if (fabricsWithoutSessions > 0 || this.#commissioningMode !== CommissioningMode.NotCommissioning) { await broadcaster.announce(); } diff --git a/packages/protocol/src/protocol/DeviceCommissioner.ts b/packages/protocol/src/protocol/DeviceCommissioner.ts index 8be4124e46..5bf12057ea 100644 --- a/packages/protocol/src/protocol/DeviceCommissioner.ts +++ b/packages/protocol/src/protocol/DeviceCommissioner.ts @@ -89,7 +89,7 @@ export class DeviceCommissioner { // When session was closed and no fabric exist anymore then this is triggering a factory reset in upper // layer and it would be not good to announce a commissionable device and then reset that again with the // factory reset - if (this.#context.fabrics.getFabrics().length > 0 || session.isPase || !existingSessionFabric) { + if (this.#context.fabrics.length > 0 || session.isPase || !existingSessionFabric) { this.#context.advertiser .startAdvertising() .catch(error => logger.warn(`Error while announcing`, error)); @@ -159,7 +159,7 @@ export class DeviceCommissioner { this.#failsafeContext = failsafeContext; this.#context.fabrics.events.added.on(fabric => { - const fabrics = this.#context.fabrics.getFabrics(); + const fabrics = this.#context.fabrics.fabrics; this.#context.advertiser .advertiseFabrics(fabrics, true) .catch(error => @@ -252,7 +252,7 @@ export class DeviceCommissioner { // Remove PASE responder when we close enhanced commissioning window or node is commissioned if ( this.#windowStatus === AdministratorCommissioning.CommissioningWindowStatus.EnhancedWindowOpen || - this.#context.fabrics.getFabrics().length > 0 + this.#context.fabrics.length > 0 ) { this.#context.secureChannelProtocol.removePaseCommissioner(); } diff --git a/packages/protocol/src/session/SessionManager.ts b/packages/protocol/src/session/SessionManager.ts index 99d181f261..00e3e1f51d 100644 --- a/packages/protocol/src/session/SessionManager.ts +++ b/packages/protocol/src/session/SessionManager.ts @@ -473,7 +473,7 @@ export class SessionManager { } = {}, caseAuthenticatedTags, }) => { - const fabric = this.#context.fabrics.getFabrics().find(fabric => fabric.fabricId === fabricId); + const fabric = this.#context.fabrics.find(fabric => fabric.fabricId === fabricId); logger.info( "restoring resumption record for node", nodeId, diff --git a/packages/tools/src/testing/mocks/time.ts b/packages/tools/src/testing/mocks/time.ts index db20078a4d..c2a7840c21 100644 --- a/packages/tools/src/testing/mocks/time.ts +++ b/packages/tools/src/testing/mocks/time.ts @@ -287,6 +287,7 @@ let reinstallTime: undefined | (() => void); export function timeSetup(Time: { get(): unknown }) { real = Time.get(); + (MockTime as any).sleep = (real as any).sleep; reinstallTime = () => (Time.get = () => MockTime.activeImplementation); reinstallTime(); } diff --git a/packages/types/src/commissioning/CommissioningOptions.ts b/packages/types/src/commissioning/CommissioningOptions.ts index 9cc067b873..e2e7bb0e38 100644 --- a/packages/types/src/commissioning/CommissioningOptions.ts +++ b/packages/types/src/commissioning/CommissioningOptions.ts @@ -18,6 +18,13 @@ export namespace CommissioningOptions { ]; export interface Configuration { + /** + * Allow commissioning of this device by one or more controllers. This is enabled by default. + * + * It only makes sense for a device to be uncommissionable if it is itself a controller. + */ + readonly enabled: boolean; + /** * Product details included in commissioning advertisements. */