diff --git a/packages/matter-node.js-examples/src/examples/DeviceNodeFull.ts b/packages/matter-node.js-examples/src/examples/DeviceNodeFull.ts index ca1392626..5488daa18 100644 --- a/packages/matter-node.js-examples/src/examples/DeviceNodeFull.ts +++ b/packages/matter-node.js-examples/src/examples/DeviceNodeFull.ts @@ -41,7 +41,7 @@ import { Endpoint, EndpointServer } from "@project-chip/matter.js/endpoint"; import { RootRequirements } from "@project-chip/matter.js/endpoint/definitions"; import { Environment, StorageService } from "@project-chip/matter.js/environment"; import { FabricAction } from "@project-chip/matter.js/fabric"; -import { Level, Logger, levelFromString } from "@project-chip/matter.js/log"; +import { Logger, levelFromString } from "@project-chip/matter.js/log"; import { ServerNode } from "@project-chip/matter.js/node"; import { QrCode } from "@project-chip/matter.js/schema"; import { Time } from "@project-chip/matter.js/time"; @@ -103,7 +103,7 @@ function executeCommand(scriptParamName: string) { const logFile = environment.vars.string("logfile.filename"); if (logFile !== undefined) { Logger.addLogger("filelogger", await createFileLogger(logFile), { - defaultLogLevel: levelFromString(environment.vars.string("logfile.loglevel")) ?? Level.DEBUG, + defaultLogLevel: levelFromString(environment.vars.string("logfile.loglevel", "debug")), }); } diff --git a/packages/matter-node.js-examples/src/examples/cluster/DummyThreadNetworkCommissioningServer.ts b/packages/matter-node.js-examples/src/examples/cluster/DummyThreadNetworkCommissioningServer.ts index 7ec0ed0e8..281f5ddf9 100644 --- a/packages/matter-node.js-examples/src/examples/cluster/DummyThreadNetworkCommissioningServer.ts +++ b/packages/matter-node.js-examples/src/examples/cluster/DummyThreadNetworkCommissioningServer.ts @@ -43,7 +43,7 @@ export class DummyThreadNetworkCommissioningServer extends NetworkCommissioningB const threadScanResults = [ { panId: this.endpoint.env.vars.number("ble.thread.panId"), - extendedPanId: BigInt(this.endpoint.env.vars.string("ble.thread.extendedPanId")), + extendedPanId: this.endpoint.env.vars.bigint("ble.thread.extendedPanId"), networkName: this.endpoint.env.vars.string("ble.thread.networkName"), channel: this.endpoint.env.vars.number("ble.thread.channel"), version: 130, diff --git a/packages/matter-node.js/src/net/UdpChannelNode.ts b/packages/matter-node.js/src/net/UdpChannelNode.ts index ca9aee349..78afbbaa7 100644 --- a/packages/matter-node.js/src/net/UdpChannelNode.ts +++ b/packages/matter-node.js/src/net/UdpChannelNode.ts @@ -47,11 +47,15 @@ export class UdpChannelNode implements UdpChannel { listeningAddress, netInterface, membershipAddresses, + reuseAddress, }: UdpChannelOptions) { - const socketOptions: dgram.SocketOptions = { type, reuseAddr: true }; + const socketOptions: dgram.SocketOptions = { type: type === "udp" ? "udp6" : type }; if (type === "udp6") { socketOptions.ipv6Only = true; } + if (reuseAddress) { + socketOptions.reuseAddr = true; + } const socket = await createDgramSocket(listeningAddress, listeningPort, socketOptions); socket.setBroadcast(true); let netInterfaceZone: string | undefined; diff --git a/packages/matter.js/src/behavior/system/network/NetworkRuntime.ts b/packages/matter.js/src/behavior/system/network/NetworkRuntime.ts index ed0c4f1ee..909511038 100644 --- a/packages/matter.js/src/behavior/system/network/NetworkRuntime.ts +++ b/packages/matter.js/src/behavior/system/network/NetworkRuntime.ts @@ -62,8 +62,6 @@ export abstract class NetworkRuntime { } } - abstract operationalPort: number; - protected abstract start(): Promise; protected abstract stop(): Promise; diff --git a/packages/matter.js/src/behavior/system/network/NetworkServer.ts b/packages/matter.js/src/behavior/system/network/NetworkServer.ts index 75a858bc6..00cae6105 100644 --- a/packages/matter.js/src/behavior/system/network/NetworkServer.ts +++ b/packages/matter.js/src/behavior/system/network/NetworkServer.ts @@ -5,7 +5,7 @@ */ import { Ble } from "../../../ble/Ble.js"; -import { ImplementationError } from "../../../common/MatterError.js"; +import { ImplementationError, NotImplementedError } from "../../../common/MatterError.js"; import { Logger } from "../../../log/Logger.js"; import { SubscriptionOptions } from "../../../protocol/interaction/SubscriptionOptions.js"; import { TypeFromPartialBitSchema } from "../../../schema/BitmapSchema.js"; @@ -27,13 +27,11 @@ export class NetworkServer extends NetworkBehavior { declare internal: NetworkServer.Internal; override initialize() { - if (this.state.ble === undefined) { - // TODO make working again when State init gets fixed! - this.state.ble = Ble.enabled; - } else if (this.state.ble && !Ble.enabled) { - logger.warn("Disabling Bluetooth commissioning because BLE support is not installed"); - this.state.ble = false; - } + const vars = this.endpoint.env.vars; + + this.state.port = vars.number("network.port", this.state.port); + + this.state.listen = this.#configureListeners(vars.list("network.listen", this.state.listen)); const discoveryCaps = this.state.discoveryCapabilities; switch (discoveryCaps.ble) { @@ -101,21 +99,156 @@ export class NetworkServer extends NetworkBehavior { this.internal.runtime.endUncommissionedMode(); } } + + #configureListeners(config: unknown[]) { + const listen = Array(); + let hasUdp = false; + let hasBle = false; + let disabledBle = false; + for (const addr of config) { + if (typeof addr !== "object") { + throw new ImplementationError("Listen address is not an object"); + } + + let { transport, port } = addr as Record; + const { address } = addr as Record; + + if (transport === undefined) { + transport = "udp"; + } + + switch (transport) { + case "ble": + if (Ble.enabled) { + if (hasBle) { + throw new NotImplementedError("Currently only a single BLE transport is allowed"); + } else { + hasBle = true; + } + if (address !== undefined) { + throw new NotImplementedError("Currently you may not specify the BLE transport address"); + } + listen.push({ transport: transport, address }); + this.state.ble = true; + } else { + disabledBle = true; + } + break; + + case "udp": + case "udp4": + case "udp6": + hasUdp = true; + if (port === undefined) { + port = this.state.port; + } + listen.push({ transport: transport, address, port }); + break; + + default: + throw new ImplementationError(`Unknown listen protocol "${transport}"`); + } + } + + if (disabledBle) { + logger.warn("Disabling Bluetooth commissioning because BLE support is not installed"); + this.state.ble = false; + } else if (this.state.ble !== false && Ble.enabled) { + if (!hasBle) { + listen.push({ transport: "ble" }); + } + this.state.ble = true; + } + + if (!hasUdp) { + listen.push({ transport: "udp", port: this.state.port }); + } + + return listen; + } } export namespace NetworkServer { + /** + * A UDP listening address. + */ + export interface UdpAddress { + transport: "udp" | "udp4" | "udp6"; + + /** + * The hostname or IP address. Leave undefined for all addresses, "0.0.0.0" for all IPv4 addresses, and "::" + * for all IPv6 addresses. + */ + address?: string; + + /** + * The port to listen on. Defaults to {@link State.port}. + */ + port?: number; + } + + /** + * A Bluetooth LE listening address, + * + * TODO - currently only a single BLE transport is supported + */ + export interface BleAddress { + transport: "ble"; + + /** + * The HCI ID of the bluetooth adapter. + * + * By default selects the first adapter on the system. + * + * TODO - currently you cannot specify HCI ID here + */ + address?: string; + } + + export type Address = BleAddress | UdpAddress; + export class Internal extends NetworkBehavior.Internal { declare runtime: ServerNetworkRuntime; } export class State extends NetworkBehavior.State { - listeningAddressIpv4?: string = undefined; - listeningAddressIpv6?: string = undefined; - ipv4 = true; + /** + * An array of {@link Address} objects configuring the interfaces the server listens on. + * + * Configurable also with variable "network.listen". You may configure a single listener using: + * + * * `network.listen.transport` either "ble", "udp4", "udp6" or "udp" (default is "udp" for dual IPv4/6) + * * `network.listen.address` the hostname, IP address (default all) or HCI ID (default first) to listen on + * * `network.listen.port` the port for UDP listeners (default is 5540) + * + * You may configure multiple listeners using `network.listen.0`, `network.listen.1`, etc. with the same subkeys + * as above. + * + * At least one UDP listener is required. The server will add one if none are present. + * + * If {@link ble} is true, the server will add a BLE listener as well if none are present and Matter.js supports + * BLE on the current platform. + */ + listen = Array
(); + + /** + * Controls whether BLE is added to the default configuration. If undefined, BLE is enabled if present on the + * system. + * + * Once the server starts this value reflects the current state of BLE for the node. + */ ble?: boolean = undefined; + + /** + * The Matter capabilities the server broadcasts. + */ discoveryCapabilities: TypeFromPartialBitSchema = { onIpNetwork: true, }; + + /** + * Time intervales for subscription configuration. + */ subscriptionOptions?: SubscriptionOptions = undefined; } } diff --git a/packages/matter.js/src/behavior/system/network/ServerNetworkRuntime.ts b/packages/matter.js/src/behavior/system/network/ServerNetworkRuntime.ts index a02f23ce9..80cdf2102 100644 --- a/packages/matter.js/src/behavior/system/network/ServerNetworkRuntime.ts +++ b/packages/matter.js/src/behavior/system/network/ServerNetworkRuntime.ts @@ -32,9 +32,9 @@ export class ServerNetworkRuntime extends NetworkRuntime { #interactionServer?: TransactionalInteractionServer; #matterDevice?: MatterDevice; #mdnsBroadcaster?: MdnsInstanceBroadcaster; - #primaryNetInterface?: UdpInterface; + #transports?: Set; + #bleTransports?: Set; #bleBroadcaster?: InstanceBroadcaster; - #bleTransport?: TransportInterface; #commissionedListener?: () => void; override get owner() { @@ -81,22 +81,56 @@ export class ServerNetworkRuntime extends NetworkRuntime { } /** - * The IPv6 {@link UdpInterface}. We create this interface independently of the server so the OS can select a port - * before we are fully online. + * The {@link UdpInterface} instances we listen on. We create these independently of the server so the OS can + * select a port before we are fully online. */ - protected async getPrimaryNetInterface() { - if (this.#primaryNetInterface === undefined) { - const port = this.owner.state.network.port; - this.#primaryNetInterface = await UdpInterface.create( - this.owner.env.get(Network), - "udp6", - port ? port : undefined, - this.owner.state.network.listeningAddressIpv6, - ); - - await this.owner.set({ network: { operationalPort: this.#primaryNetInterface.port } }); + protected async getTransports() { + if (this.#transports !== undefined) { + return this.#transports; } - return this.#primaryNetInterface; + + const transports = new Set(); + const bleTransports = new Set(); + + for (const address of this.owner.state.network.listen) { + switch (address.transport) { + case "udp": + case "udp4": + case "udp6": + const udp = await UdpInterface.create( + this.owner.env.get(Network), + address.transport, + address.port, + address.address, + ); + + transports.add(udp); + + // We advertise the first (most likely only) UDP port we open as our official address + await this.owner.act(agent => { + const state = agent.network.state; + if (state.operationalPort === -1) { + state.operationalPort = udp.port; + } + }); + + break; + + case "ble": + // TODO - HCI ID and/or multiple BLE transports? (Former seems valuable, latter not really but + // would include for completeness) + const ble = Ble.get().getBlePeripheralInterface(); + transports.add(ble); + bleTransports.add(ble); + break; + } + } + + // AFAICT no way to query transport interface for protocol type so just store BLE transports separately so we + // can identify them for removal after commissioning completes + this.#bleTransports = bleTransports; + + return (this.#transports = transports); } /** @@ -110,40 +144,6 @@ export class ServerNetworkRuntime extends NetworkRuntime { return this.#bleBroadcaster; } - /** - * A BLE transport. - */ - protected get bleTransport() { - if (this.#bleTransport === undefined) { - this.#bleTransport = Ble.get().getBlePeripheralInterface(); - } - return this.#bleTransport; - } - - /** - * Add transports to the {@link MatterDevice}. - */ - protected async addTransports(device: MatterDevice) { - device.addTransportInterface(await this.getPrimaryNetInterface()); - - const netconf = this.owner.state.network; - - if (netconf.ipv4) { - device.addTransportInterface( - await UdpInterface.create( - this.owner.env.get(Network), - "udp4", - netconf.port, - netconf.listeningAddressIpv4, - ), - ); - } - - if (netconf.ble) { - device.addTransportInterface(this.bleTransport); - } - } - /** * Add broadcasters to the {@link MatterDevice}. */ @@ -187,20 +187,23 @@ export class ServerNetworkRuntime extends NetworkRuntime { this.#bleBroadcaster = undefined; } - if (this.#bleTransport) { - this.owner.env.runtime.add(this.#removeBleTransport(this.#bleTransport)); - this.#bleTransport = undefined; + if (this.#bleTransports) { + for (const ble of this.#bleTransports) { + this.owner.env.runtime.add(this.#removeBleTransport(ble)); + } } } - async #removeBleBroadcaster(bleBroadcaster: InstanceBroadcaster) { - await this.#matterDevice?.deleteBroadcaster(bleBroadcaster); - await bleBroadcaster.close(); + async #removeBleBroadcaster(broadcaster: InstanceBroadcaster) { + await this.#matterDevice?.deleteBroadcaster(broadcaster); + await broadcaster.close(); } - async #removeBleTransport(bleTransport: TransportInterface) { - await this.#matterDevice?.deleteTransportInterface(bleTransport); - await bleTransport.close(); + async #removeBleTransport(transport: TransportInterface) { + this.#transports?.delete(transport); + this.#bleTransports?.delete(transport); + await this.#matterDevice?.deleteTransportInterface(transport); + await transport.close(); } /** @@ -217,10 +220,6 @@ export class ServerNetworkRuntime extends NetworkRuntime { return this.owner.state.operationalCredentials.commissionedFabrics; } - override get operationalPort() { - return this.#primaryNetInterface?.port ?? 0; - } - async endCommissioning() { if (this.#matterDevice !== undefined) { return this.#matterDevice.endCommissioning(); @@ -266,10 +265,11 @@ export class ServerNetworkRuntime extends NetworkRuntime { await this.owner.act(agent => agent.load(SessionsBehavior)); this.owner.eventsOf(CommissioningBehavior).commissioned.on(() => this.endUncommissionedMode()); - await this.addTransports(this.#matterDevice); - await this.addBroadcasters(this.#matterDevice); + for (const transport of await this.getTransports()) { + this.#matterDevice.addTransportInterface(transport); + } - await this.owner.set({ network: { operationalPort: this.operationalPort } }); + await this.addBroadcasters(this.#matterDevice); await this.openAdvertisementWindow(); } @@ -282,13 +282,13 @@ export class ServerNetworkRuntime extends NetworkRuntime { await this.#matterDevice.close(); this.#matterDevice = undefined; - this.#primaryNetInterface = undefined; - } - - if (this.#primaryNetInterface) { - // If we created the net interface but not the device we need to dispose ourselves - await this.#primaryNetInterface.close(); - this.#primaryNetInterface = undefined; + this.#transports = undefined; + } else if (this.#transports) { + // We created the transports but not the device; so we need to dispose ourselves + for (const transport of this.#transports) { + await transport.close(); + } + this.#transports = undefined; } await this.#interactionServer?.[Symbol.asyncDispose](); diff --git a/packages/matter.js/src/environment/VariableService.ts b/packages/matter.js/src/environment/VariableService.ts index 4d4d18891..db7abf6b5 100644 --- a/packages/matter.js/src/environment/VariableService.ts +++ b/packages/matter.js/src/environment/VariableService.ts @@ -38,13 +38,13 @@ export class VariableService { get(name: string, fallback?: VariableService.Value) { switch (typeof fallback) { case "string": - return this.string(name) ?? fallback; + return this.string(name, fallback); case "number": - return this.number(name) ?? fallback; + return this.number(name, fallback); case "boolean": - return this.boolean(name) ?? fallback; + return this.boolean(name, fallback); } let value: VariableService.Value = this.#vars; @@ -77,10 +77,14 @@ export class VariableService { parent[key] = value; } - string(name: string) { + string(name: string): string | undefined; + + string(name: string, fallback: string): string; + + string(name: string, fallback?: string) { const value = this.get(name); if (value === undefined) { - return value; + return fallback; } if (typeof value === "string") { return value; @@ -91,11 +95,15 @@ export class VariableService { return value.toString(); } - boolean(name: string) { + boolean(name: string): boolean | undefined; + + boolean(name: string, fallback: boolean): boolean; + + boolean(name: string, fallback?: boolean) { const value = this.get(name); switch (value) { case undefined: - return value; + return fallback; case null: case 0: @@ -109,7 +117,11 @@ export class VariableService { } } - number(name: string) { + number(name: string): number | undefined; + + number(name: string, fallback: number): number; + + number(name: string, fallback?: number) { let value = this.get(name); if (typeof value === "number") { return value; @@ -117,12 +129,35 @@ export class VariableService { if (typeof value === "string") { value = Number.parseFloat(value); if (Number.isNaN(value)) { - return; + return fallback; } return value; } + return fallback; + } + + bigint(name: string): bigint | undefined; + + bigint(name: string, fallback: bigint): bigint; + + bigint(name: string, fallback?: bigint) { + const value = this.get(name); + + if (typeof value === "bigint") { + return value; + } + + if (typeof value === "number" || typeof value === "string") { + return BigInt(value); + } + + return fallback; } + integer(name: string): number | undefined; + + integer(name: string, fallback: number): number; + integer(name: string, fallback?: number) { const number = this.number(name) ?? fallback; if (typeof number === "number") { @@ -130,6 +165,40 @@ export class VariableService { } } + list(name: string): unknown[] | undefined; + + list(name: string, fallback: unknown[]): unknown[]; + + list(name: string, fallback?: unknown[]): unknown[] | undefined { + const value = this.get(name); + if (value === undefined || value === null) { + return fallback; + } + + if (Array.isArray(value)) { + return value; + } + + if (typeof value === "object") { + const keys = Object.keys(value); + if (!keys.length) { + return fallback; + } + + let isArrayLike = true; + for (const key of keys) { + if (!key.match(/^[0-9]+$/)) { + isArrayLike = false; + } + } + if (isArrayLike) { + return Object.values(value); + } + } + + return [value]; + } + increment(name: string) { const value = this.integer(name) ?? 0; this.set(name, value + 1); diff --git a/packages/matter.js/src/net/UdpChannel.ts b/packages/matter.js/src/net/UdpChannel.ts index 4f98fa49b..c18bc4dcd 100644 --- a/packages/matter.js/src/net/UdpChannel.ts +++ b/packages/matter.js/src/net/UdpChannel.ts @@ -8,10 +8,32 @@ import { Listener } from "../common/TransportInterface.js"; import { ByteArray } from "../util/ByteArray.js"; export interface UdpChannelOptions { + /** + * UDP channel type. "udp4" and "udp6" mean IPv4 and IPv6 respectively. "udp" is dual-mode IPv4/IPv6. + * {@link listeningAddress} in this case must be undefined or "::". + */ + type: "udp" | "udp4" | "udp6"; + + /** + * The port to listen on. undefined or 0 directs the operating system to select an open port. + */ listeningPort?: number; - type: "udp4" | "udp6"; + + /** + * The address to listen on, either a hostname or IP address in correct format based on {@link type}. + */ listeningAddress?: string; + + /** + * If true the socket is opened non-exclusively. + */ + reuseAddress?: boolean; + + /** + * The network interface, required for multicast. + */ netInterface?: string; + membershipAddresses?: string[]; } diff --git a/packages/matter.js/src/net/UdpInterface.ts b/packages/matter.js/src/net/UdpInterface.ts index ce26b19c9..f150b6008 100644 --- a/packages/matter.js/src/net/UdpInterface.ts +++ b/packages/matter.js/src/net/UdpInterface.ts @@ -13,7 +13,13 @@ import { Network, NetworkError } from "./Network.js"; import { UdpChannel } from "./UdpChannel.js"; export class UdpInterface implements NetInterface { - static async create(network: Network, type: "udp4" | "udp6", port?: number, host?: string, netInterface?: string) { + static async create( + network: Network, + type: "udp" | "udp4" | "udp6", + port?: number, + host?: string, + netInterface?: string, + ) { return new UdpInterface( await network.createUdpChannel({ listeningPort: port, type, netInterface, listeningAddress: host }), ); diff --git a/packages/matter.js/src/net/UdpMulticastServer.ts b/packages/matter.js/src/net/UdpMulticastServer.ts index 60eccb711..e113d4f51 100644 --- a/packages/matter.js/src/net/UdpMulticastServer.ts +++ b/packages/matter.js/src/net/UdpMulticastServer.ts @@ -41,12 +41,14 @@ export class UdpMulticastServer { netInterface, listeningPort, membershipAddresses: [broadcastAddressIpv4], + reuseAddress: true, }), await network.createUdpChannel({ type: "udp6", netInterface, listeningPort, membershipAddresses: [broadcastAddressIpv6], + reuseAddress: true, }), netInterface, ); @@ -122,6 +124,7 @@ export class UdpMulticastServer { type: iPv4 ? "udp4" : "udp6", listeningPort: this.broadcastPort, netInterface, + reuseAddress: true, }); } diff --git a/packages/matter.js/src/net/fake/UdpChannelFake.ts b/packages/matter.js/src/net/fake/UdpChannelFake.ts index bf83609a3..eea1ca1ef 100644 --- a/packages/matter.js/src/net/fake/UdpChannelFake.ts +++ b/packages/matter.js/src/net/fake/UdpChannelFake.ts @@ -35,7 +35,7 @@ export class UdpChannelFake implements UdpChannel { private readonly listeningAddress: string | undefined, listeningPort?: number, ) { - this.listeningPort = listeningPort ?? 1024 + Math.floor(Math.random() * 64511); // Random port 1024-65535 + this.listeningPort = listeningPort ? listeningPort : 1024 + Math.floor(Math.random() * 64511); // Random port 1024-65535 } onData(listener: (netInterface: string, peerAddress: string, peerPort: number, data: ByteArray) => void) {