/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { OnOffLightDevice } from "#devices/on-off-light"; import { Node } from "#node/Node.js"; import { ServerNode } from "#node/ServerNode.js"; import { Crypto, Entropy, Environment, MatterAggregateError, MemoryStorageDriver, MockCrypto, MockStorageService, Network, NetworkSimulator, RuntimeService, Seconds, StorageDriver, } from "@matter/general"; import { MatterModel } from "@matter/model"; import { FabricId } from "@matter/types"; import { MockServerNode } from "./mock-server-node.js"; /** * Manages a mock network with nodes on it. */ export class MockSite { #simulator = new NetworkSimulator(); #nodes = new Set(); #environments = new Set(); #nextNetworkIndex = 1; #storage = {} as Record>; #createStorageDriver: (store: Record) => StorageDriver; /** * Creates an empty site. All nodes added share one network simulator unless a node brings its own. */ constructor(options?: MockSite.Options) { this.#createStorageDriver = options?.createStorageDriver ?? (store => new MemoryStorageDriver(store)); } /** * Adds a plain {@link ServerNode} (not a {@link MockServerNode}) with mock crypto, a simulated network host and * site-backed storage, and starts it unless `online` is `false`. Missing `index` and `id` are assigned (`device`). * The site closes the node and any environment it created on {@link close}. */ addNode( type?: T, options?: MockServerNode.Options, ): Promise>; addNode( definition: T | MockServerNode.Configuration, options?: MockServerNode.Options, ): Promise>; async addNode( definition: T | MockServerNode.Configuration, options?: MockServerNode.Options, ) { const config = Node.nodeConfigFor( MockServerNode.RootEndpoint as T, definition, options ?? ({} as MockServerNode.Options), ); const index = (config.index ??= this.#nextNetworkIndex++); const id = (config.id ??= `device${index}`); if (config.environment === undefined) { config.environment = new Environment(id); this.#environments.add(config.environment); } const env = config.environment; if (!env.has(Crypto)) { const crypto = MockCrypto(index); env.set(Entropy, crypto); env.set(Crypto, crypto); } if (!env.has(Network)) { env.set(Network, (config.simulator ?? this.#simulator).addHost(index)); } new MockStorageService(env, () => this.#createStorageDriver(this.storageFor(id))); // Note that we don't use MockServerNode as we don't actually want anything mocked const node = config.matter === undefined ? new ServerNode(config) : new ModelledServerNode(config, config.matter); this.#nodes.add(node); if (config.device) { await node.add(config.device); } if (config.online !== false) { await node.start(); } else { await node.construction; } node.lifecycle.destroyed.once(() => { this.#nodes.delete(node); }); return node; } /** * Adds an offline controller node with commissioning disabled and admin fabric id 1. Missing `index` and `id` are assigned (`controller`); the options object is modified. */ async addController(options?: MockServerNode.Options) { options ??= {}; const index = (options.index ??= this.#nextNetworkIndex++); const id = (options.id ??= `controller${index}`); if (options.controller?.adminFabricId === undefined) { options.controller ??= {}; options.controller.adminFabricId = FabricId(1); } return await this.addNode(undefined, { online: false, id, index, ...options, commissioning: { enabled: false, ...options.commissioning }, }); } /** * Adds a node with an On/Off Light device and starts it, unless the options say otherwise. */ async addDevice(options?: MockServerNode.Options) { return await this.addNode(undefined, { device: OnOffLightDevice, ...options, }); } /** * Adds a controller (not started) and a device, without commissioning them. */ async addUncommissionedPair(options?: MockSite.PairOptions) { options ??= {}; const controller = await this.addController(options.controller); const device = await this.addDevice(options.device); return { controller, device }; } /** * Adds a controller and a device, starts the controller and commissions the device into its fabric using mock time (90 s * timeout). Entropy is enabled on both only while pairing. */ async addCommissionedPair(options?: MockSite.PairOptions) { const { controller, device } = await this.addUncommissionedPair(options); const controllerCrypto = controller.env.get(Crypto) as MockCrypto; const deviceCrypto = device.env.get(Crypto) as MockCrypto; // We end up with session collisions without entropy so enable during pairing controllerCrypto.entropic = deviceCrypto.entropic = true; if (!controller.lifecycle.isOnline) { await controller.start(); } const { passcode, discriminator } = device.state.commissioning; await MockTime.resolve(controller.peers.commission({ passcode, discriminator, timeout: Seconds(90) }), { macrotasks: true, }); controllerCrypto.entropic = deviceCrypto.entropic = false; return { controller, device }; } /** * Closes all nodes still open, then the environments the site created for them. */ async close() { await MockTime.resolve( MatterAggregateError.allSettled( [...this.#nodes].map(async node => { await node.close(); }), ), // Not sure why macrotasks are necessary; something hangs with microtasks but haven't tracked down { macrotasks: true }, ); const environments = [...this.#environments]; this.#environments.clear(); await MockTime.resolve( MatterAggregateError.allSettled( environments.map(async env => { if (env.owns(RuntimeService)) { await env.get(RuntimeService).close(); } env[Symbol.dispose](); }), ), { macrotasks: true }, ); } /** * The in-memory store backing the given node id (or node), created on first use. Persists across node restarts within the site. */ storageFor(id: string | { id: string }) { if (typeof id !== "string") { id = id.id; } if (!(id in this.#storage)) { this.#storage[id] = {}; } return this.#storage[id]; } /** * Closes the site, so it can be used with `await using`. */ async [Symbol.asyncDispose]() { await this.close(); } } export namespace MockSite { /** * Options for {@link MockSite}. */ export interface Options { /** * Creates the storage driver for a node, given its backing store. Defaults to a memory driver. */ createStorageDriver?: (store: Record) => StorageDriver; } /** * Per-node configuration for the pair-creating methods of {@link MockSite}. */ export interface PairOptions { /** * Configuration of the controller node. */ controller?: MockServerNode.Configuration; /** * Configuration of the device node. */ device?: MockServerNode.Configuration; } } /** * A server node that validates its device types in a model other than the standard one. */ class ModelledServerNode extends ServerNode { #matter: MatterModel; constructor(config: Partial>, matter: MatterModel) { super(config); this.#matter = matter; } override get matter() { return this.#matter; } }