diff --git a/CHANGELOG.md b/CHANGELOG.md index d1eba19eab..5c53a25b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ The main work (all changes without a GitHub username in brackets in the below li ## __WORK IN PROGRESS__ --> +## __WORK IN PROGRESS__ + +- @matter/node + - Feature: Added `ServerNode.peers.commissioned` returning the commissioned `ClientNode`s + - Feature: Added `ClientNode.disable()`/`enable()` to persistently disable/enable a commissioned peer + - Feature: Added a `ClientNode` connection-state engine — `lifecycle.connectionState`/`connectionStateChanged`/`isConnected` and the `NodeConnectionState` enum + - Feature: Added `ClientNodeLifecycle.isSeeded` and the `seeded` event, indicating a peer node's structure has been read from the device at least once + - Feature: Added `Behaviors.forCluster(clusterId)` to resolve a cluster behavior type by numeric cluster id + - Feature: Added `openBasicCommissioningWindow`/`openEnhancedCommissioningWindow` on `CommissioningClient`/`ClientNode` to open a commissioning window on a commissioned peer + - Feature: Added split/delegated commissioning — `CommissioningClient.CommissioningOptions.finalizeCommissioning` plus `ServerNode.peers.completeCommissioning(nodeId, discoveryData?, options?)` + - Feature: Added `NetworkServer.autoStartCommissionedPeers` (default true) to opt out of auto-starting commissioned peers when the node goes online + - Adjustment: A node with commissioning disabled (e.g. a controller) now binds an ephemeral operational port instead of the standard Matter port (5540) when `NetworkServer.port` is unset; commissionable nodes still default to 5540 and an explicit port is always honored + - Fix: `endpoints.size` no longer double-counts the root endpoint + +- @matter/types + - Feature: Added the `ClusterLookup` namespace for cluster/attribute/command/event name↔id resolution (optional `MatterModel` for custom clusters) + +- @project-chip/matter.js + - Deprecation: The legacy controller/device API (CommissioningController, PairedNode, Device/Endpoint/Aggregator, cluster clients) is officially deprecated and will be removed in 0.19; migrate to @matter/node + ## 0.17.7 (2026-07-27) - @matter/general diff --git a/docs/MIGRATION_CONTROLLER_018.md b/docs/MIGRATION_CONTROLLER_018.md new file mode 100644 index 0000000000..4b73e20dc0 --- /dev/null +++ b/docs/MIGRATION_CONTROLLER_018.md @@ -0,0 +1,675 @@ +# Migration guide: Controller API to 0.18 (PairedNode/CommissioningController → ClientNode) + +> **Status: WORK IN PROGRESS.** This guide is written alongside the actual migration of the +> matter.js shell and the matter.js WebSocket server. Sections are filled with verified before/after +> code as each capability is migrated. Open items are marked `TODO`. + +With 0.18 the legacy controller API is being deprecated. The two legacy entry points are: + +- **`CommissioningController`** — the controller wrapper (commission, enumerate/get nodes, discovery, + fabric). It is being replaced by a controller **`ServerNode`** whose commissioned peers are + **`ClientNode`** instances in `serverNode.peers`. +- **`PairedNode`** — the per-peer wrapper returned by the controller. It is being replaced by the + peer's **`ClientNode`** directly. Most `PairedNode` members already just delegate to the underlying + `ClientNode` (`PairedNode.node`), so a large part of the migration is removing the `.node` hop. + +The legacy API stays functional through the deprecation period but will be removed. New code and +migrating consumers should target the `ClientNode` API. + +--- + +## Quick map: what moved how + +### Already 1:1 (PairedNode already delegates — just drop `PairedNode`, use the `ClientNode`) + +`state`, `commands`, `stateOf`, `maybeStateOf`, `commandsOf`, `featuresOf`, `maybeFeaturesOf`, +`globalsOf`, `maybeGlobalsOf`, `get`, `getStateOf`, `eventsOf`, `id`, `decommission`, `close`, +`disconnect` (→ `disable()`). + +Device info: `basicInformation` / `deviceInformation` → `new DeviceInformation(clientNode).basicInformation` / `.meta` +(or read the `BasicInformationClient` behavior state directly). + +### Paradigm shift (no more `ClusterClient` objects) + +| Legacy | New | +| --- | --- | +| `node.getDevices()` / `getDeviceById(id)` | `clientNode.endpoints` / `clientNode.endpoints.for(id)` | +| `node.getRootEndpoint()` | the `ClientNode` **is** the root endpoint | +| `node.getRootClusterClient(Cluster)` | root endpoint `stateOf(XxxClient)` / `commandsOf(XxxClient)` / `act(agent => agent.get(XxxClient)…)` | +| `node.getClusterClientForDevice(id, Cluster)` | `clientNode.endpoints.for(id).stateOf/commandsOf(XxxClient)` | +| `clusterClient.attributes[x].get()` / `.set()` | behavior `stateOf(...)` read / `set({...})` write | +| `clusterClient.commands[x](req)` | `endpoint.act(a => a.get(XxxClient).x(req))` | +| `node.getInteractionClient()` | `clientNode.interaction` (`Interactable`) | +| `node.logStructure()` | `logger.info(clientNode)` (node is directly loggable) | + +### Controller: `CommissioningController` → controller `ServerNode` + +| Legacy | New | +| --- | --- | +| `new CommissioningController({...})` | controller `ServerNode` (+ `ControllerBehavior`) | +| `controller.commissionNode(opts)` | `serverNode.peers.commission(opts)` / discovered → `clientNode.commission(opts)` | +| `controller.getNode(nodeId)` / `getPairedNode(nodeId)` | `serverNode.peers.get(nodeId \| address)` (returns `ClientNode`) | +| `controller.getNode(nodeId, allowUnknown)` | `serverNode.peers.forAddress(address)` | +| `controller.getCommissionedNodes()` | `serverNode.peers.commissioned` → map `peerAddress.nodeId` | +| `controller.getCommissionedNodesDetails()` | `serverNode.peers.commissioned` + per-node `stateOf(CommissioningClient)` / `BasicInformationClient` | +| `controller.isNodeCommissioned(id)` | `serverNode.peers.get(id)?.lifecycle.isCommissioned` | +| `controller.removeNode(id, tryDecommission)` | `clientNode.decommission()` (fallback `clientNode.delete()` for force) | +| `controller.disconnectNode(id)` | `clientNode.disable()` | +| `controller.connectNode(id)` | `clientNode.enable()` / `clientNode.start()` | +| `controller.discoverCommissionableDevices(opts)` | `serverNode.peers.discover(opts)` / `peers.locate(opts)` | +| `controller.getActiveSessionInformation()` | `serverNode.stateOf(SessionsBehavior).sessions` | +| `controller.updateFabricLabel(label)` | `fabric.setLabel(label)` (+ auto per-node sync on online) — **moves to node-management layer** | +| `controller.fabric` / `nodeId` | multi-fabric via `env.get(FabricAuthority)`; common case `FabricAuthority.defaultFabric` | +| `controller.resetStorage()` | `serverNode.erase()` | +| `controller.start()` / `close()` | `serverNode.start()` / `serverNode.close()` | + +> Note: a controller `ServerNode` is **multi-fabric** (`FabricAuthority.fabrics`). Legacy `fabric`/`nodeId` +> singular getters existed because the legacy controller was single-fabric. Pick a fabric explicitly, or +> use `FabricAuthority.defaultFabric` for the single-fabric case. + +> Note: a controller (commissioning disabled) binds an **ephemeral** operational port automatically, matching +> the legacy `CommissioningController` which assigned a random `localPort`. There is no need to set +> `network: { port: 0 }`. Set `network: { port }` explicitly only if you require a fixed port. + +### Controller construction + +A controller is a `ServerNode` with `ControllerBehavior` and commissioning disabled: + +```ts +import { ControllerBehavior, ServerNode } from "@matter/node"; +import { FabricAuthority } from "@matter/protocol"; + +const node = await ServerNode.create(ServerNode.RootEndpoint.with(ControllerBehavior), { + environment, + id: "controller", + commissioning: { enabled: false }, // a controller is never commissionable itself + controller: { adminFabricLabel: "My Controller" }, // legacy `adminFabricLabel` + network: { autoStartCommissionedPeers: false }, // optional: opt out of online-time bulk connect +}); + +// Reuse the existing controller fabric (matched by CA) or create one — single-fabric case: +const fabricAuthority = await node.env.load(FabricAuthority); +const fabric = await fabricAuthority.defaultFabric({ adminFabricLabel: "My Controller" }); + +await node.start(); +``` + +A controller typically also sets `subscriptions: { persistenceEnabled: false }` (subscription persistence is a +device feature). The port is ephemeral by default (see above). + +To act as an OTA provider, add an `OtaProviderEndpoint` (`import { OtaProviderEndpoint } from +"@matter/node/endpoints/ota-provider"`); it pulls in `SoftwareUpdateManager` (query/check/download/apply +updates) and `DclBehavior` (DCL services). This is a new controller capability rather than a legacy mapping. + +### Fabric label (and propagating it to peers) — interim recipe + +`controller.updateFabricLabel(label)` did two things: set the local admin fabric label **and** push the new +label to every already-commissioned peer (`AdministratorCommissioning`/`OperationalCredentials` update per +node). The new `fabric.setLabel(label)` only does the local half; the automatic per-peer fan-out is slated for +a node-management layer that does not exist yet. Until it does, propagate it yourself: + +```ts +import { OperationalCredentialsClient } from "@matter/node"; + +await fabric.setLabel(label); // local fabric label +for (const peer of serverNode.peers.commissioned) { // push to connected peers + if (!peer.lifecycle.isConnected) continue; // skip offline; re-run on reconnect if you need them updated + await peer.act(agent => agent.get(OperationalCredentialsClient).updateFabricLabel({ label })); +} +``` + +`UpdateFabricLabel` carries only `{ label }` — it targets the fabric of the accessing session, i.e. the +controller's own fabric on that peer, so there is no fabric index to pass (and none to hardcode). Keep this +small loop in your own code (guarding offline peers, optionally retrying on `connectionStateChanged` → +`Connected`) until the node-management layer subsumes it. + +--- + +## Discovering commissionable devices + +Legacy `controller.discoverCommissionableDevices(identifierData, discoveryCapabilities, callback, timeout)` → +`serverNode.peers.discover(options)`, which returns a `ContinuousDiscovery`: + +```ts +import { ChannelType, Seconds } from "@matter/general"; + +const discovery = serverNode.peers.discover({ + longDiscriminator: 3840, // or { shortDiscriminator } / { vendorId } / { productId } / { deviceType }, or {} for all + timeout: Seconds(120), // omit to search until you call discovery.stop() + scannerFilter: scanner => // optional: restrict transports (default: all) + scanner.type === ChannelType.UDP || scanner.type === ChannelType.BLE, +}); + +// `discovered` fires per (re-)advertisement — the same device can fire repeatedly, so dedupe: +const seen = new Set(); +discovery.discovered.on(node => { + const { deviceIdentifier, addresses } = node.state.commissioning; + const id = deviceIdentifier || JSON.stringify(addresses ?? []); + if (seen.has(id)) return; + seen.add(id); + console.log(node.state.commissioning); + // discovery.stop(); // end early, e.g. on first match +}); + +const results = await discovery; // ClientNode[] once the timeout elapses (empty if no timeout was set) +``` + +Each discovered node is a `ClientNode`; its `state.commissioning` holds the identifier, `addresses` and +`deviceName`. Without a `timeout` the awaited result is always empty — rely on the `discovered` event and +`discovery.stop()`. For a single result use `serverNode.peers.locate(options)`. + +The full mDNS commissioning record (the legacy `discoverCommissionableDevices` fields) is the `DiscoveryData` +type (`@matter/protocol`): `RI` (rotating id), `PH` (pairing hint), `PI` (pairing instructions), `T` (TCP +support), `SII`/`SAI` (session idle/active intervals), plus `VP`/`DT`/`DN`/`CM`. It rides on the discovered +node's commissioning record, and for an already-commissioned peer it is on `peer.descriptor.discoveryData` +(`serverNode.env.get(PeerSet).for(peerAddress).descriptor`). So a rich discovery/descriptor response has no +gap versus the legacy record. + +--- + +## Commissioning a device + +Legacy `controller.commissionNode(options)` splits into two paths on the new API depending on whether you +already have the device's address. Both return the peer `ClientNode`; the `options` are +`CommissioningClient.CommissioningOptions`. + +Discovered (mDNS / BLE): + +```ts +import { GeneralCommissioning } from "@matter/types/clusters"; + +const node = await serverNode.peers.commission({ + passcode: 20202021, + discriminator: 3840, // or shortDiscriminator / instanceId + nodeId, // optional assigned node id + discoveryCapabilities: { onIpNetwork: true, ble }, + autoSubscribe: false, // subscribe later on demand, or true to subscribe immediately + regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.Outdoor, + regulatoryCountryCode: "XX", + onAttestationFailure: findings => findings.every(f => f.level !== "error"), // return true to accept + wifiNetwork: { wifiSsid, wifiCredentials }, // optional (for a Wi-Fi device) + threadNetwork: { networkName, operationalDataset }, // optional (for a Thread device) +}); +``` + +Known address (skip discovery): + +```ts +const node = await serverNode.peers.forDescriptor({ addresses: [{ ip, port, type: "udp" }] }); +await node.commission({ passcode: 20202021 /* ...same CommissioningOptions... */ }); +``` + +`peers.commission` runs discovery then commissions in one call; `peers.forDescriptor(...)` finds/creates the +peer node for a known address, which you then `node.commission(options)`. Wait for the peer's structure +before reading it, bounding the wait so an offline peer does not hang (see "Is the node ready?"). + +`onAttestationFailure(findings)` replaces the legacy attestation callback — return `true`/`false` to accept +or reject; each finding carries `level` (`"error"` / `"warn"` / `"info"`), `type` and `message`. To +decommission, use `node.decommission()` (contacts the device) or `node.delete()` (force-drop locally when +the peer is unreachable). + +### Delegated / split commissioning (legacy `PaseCommissioner`) + +Legacy `PaseCommissioner` performed the PASE phase to admit a device into an existing fabric, then handed +off via a callback to complete the flow elsewhere. On the new API: + +- **Adding a device that is already commissioned by another admin into your fabric** (the common + "delegated" case) is the standard Matter multi-admin flow: the current admin opens a commissioning window + on the device (`node.openEnhancedCommissioningWindow(...)` → pairing code), then your controller + `serverNode.peers.commission({ /* the enhanced pairing code's passcode + discriminator */ })`. No + `PaseCommissioner` object is involved — each admin drives an ordinary commission against its own fabric + (the controller `ServerNode` is multi-fabric via `FabricAuthority`). +- **Deciding whether to proceed after PASE** (e.g. a parallel-candidate race where only one PASE winner + should continue) is `CommissioningClient.CommissioningOptions.continueCommissioningAfterPase?: () => boolean` + — called immediately after PASE, before the main flow; return `false` to close the PASE session and stop. +- **Legacy `PaseCommissioner` (PASE here, complete-CASE elsewhere)** is now: PASE-only commissioning via + `finalizeCommissioning`, handing the result off to a controller that shares the same fabric (same CA root + + NOC signing key), which finishes with `serverNode.peers.completeCommissioning(nodeId, discoveryData?)`. + + ```ts + import { Crypto, Environment } from "@matter/general"; + import { ControllerBehavior, ServerNode } from "@matter/node"; + import { CertificateAuthority, DiscoveryData, Fabric, FabricAuthority, FabricManager } from "@matter/protocol"; + import { NodeId } from "@matter/types"; + + // serverNode already owns the fabric (built as in "Controller construction" above) — export the CA + fabric + // config so a separate controller can reconstruct the identical fabric: + const caConfig = serverNode.env.get(FabricAuthority).ca.config; // CertificateAuthority.Configuration + const fabricConfig = fabric.config; // Fabric.SyncConfig (`fabric` from defaultFabric()) + + // completingController — pre-seed a fresh environment (inheriting Crypto/Network from Environment.default) with + // the same CA *before* creating the node (fabric services are wired up during node construction), then import + // the fabric before starting: + const completingEnv = new Environment("completingController", Environment.default); + completingEnv.set(CertificateAuthority, await CertificateAuthority.create(completingEnv.get(Crypto), caConfig)); + const completingController = await ServerNode.create(ServerNode.RootEndpoint.with(ControllerBehavior), { + environment: completingEnv, + id: "completingController", + commissioning: { enabled: false }, + }); + completingController.env.get(FabricManager).addFabric(await Fabric.create(completingEnv.get(Crypto), fabricConfig)); + await completingController.start(); + + // serverNode runs PASE-only commissioning; the finalize hook triggers the completing controller and AWAITS it, + // so commission() resolves only once the device is fully committed (resolve on success, throw on failure): + await serverNode.peers.commission({ + passcode, discriminator, + finalizeCommissioning: async (address, discoveryData) => { + // Across processes this hands the nodeId + discoveryData to the other controller and awaits its result + // over your own channel; the operational connect + CommissioningComplete happen there, not here. + await completingController.peers.completeCommissioning(address.nodeId, discoveryData); + }, + }); + ``` + + `finalizeCommissioning` is awaited: it must drive the completion to done before returning (throwing rolls the + commissioning back). Do not just capture the hand-off and return — that closes the PASE session while the + device is still mid-commission. The device's failsafe is armed until `completeCommissioning` succeeds, so + finalize promptly, or the device reverts and the freshly issued NOC is discarded. + + `commission()` still sets `peerAddress`/`commissionedAt` on `serverNode`'s local node even with + `finalizeCommissioning` set, so after handing off, `serverNode` is left believing it commissioned a node it + never finished — discard that local node (`node.delete()`) once `completingController` has taken over; treat + `completingController` as the source of truth for the peer going forward. + +--- + +## Subscriptions & reconnection are automatic now + +`connect()`, `reconnect()`, `triggerReconnect()`, `subscribeAllAttributesAndEvents()` are **no longer +needed** — the `NetworkClient`'s `SustainedSubscription` establishes and re-establishes the subscription +automatically once the node is enabled. + +There is no separate "auto-connect" toggle: a node is *connected* when it is enabled (`!isDisabled`) and +holds a sustained subscription (`autoSubscribe`). The legacy node options map directly onto `NetworkClient` +state: + +| Legacy | New (`NetworkClient` state, via `clientNode.set({ network: … })`) | +| --- | --- | +| `autoConnect` | `isDisabled` (**inverse**): `autoConnect: false` → `isDisabled: true`. Or use `clientNode.enable()` / `disable()`. | +| `autoSubscribe` | `autoSubscribe` (same meaning) | +| `subscribeAllAttributesAndEvents(opts)` / interval tuning | `defaultSubscription` (a `Subscribe` / `Subscribe.Options` carrying the interval floor/ceiling); the subscription re-applies automatically when you change it | +| `node.connect()` | `clientNode.enable()` (subscription follows automatically) | +| `node.disconnect()` | `clientNode.disable()` | +| `node.reconnect()` / `triggerReconnect()` | not needed — `SustainedSubscription` re-establishes; observe `lifecycle.connectionState` | + +```ts +clientNode.set({ network: { autoSubscribe: true, defaultSubscription: { /* Subscribe.Options: interval floor/ceiling, filters */ } } }); +``` + +Notes: a newly-commissioned node performs a one-time state read on start regardless of `autoSubscribe` +(unless `autoStateInitialize` is false). With `autoSubscribe: false` the node holds no sustained +subscription, so there is no persistent liveness signal. Read current status via `NetworkClient` +(`subscriptionActive` / `subscriptionAlive`). + +Legacy `currentSubscriptionIntervalSeconds` (the *negotiated* interval, vs the requested `defaultSubscription`) +is `ClientSubscription.maxInterval` on the active subscription, held in `NetworkClient` internal state. It is +not surfaced publicly today — if a consumer needs to display it, add a one-line getter on a `NetworkClient` +subclass (`get negotiatedMaxInterval() { … activeSubscription instanceof SustainedSubscription ? .maxInterval : undefined }`). +Low effort, not a blocker. + +--- + +## Enabling / disabling nodes and bulk connect + +Per-node enable/disable is on `ClientNode`: + +| Legacy | New | +| --- | --- | +| `controller.disconnectNode(id)` / "disable node" | `clientNode.disable()` — drops the subscription and prevents reconnection until re-enabled | +| `controller.connectNode(id)` / "enable node" | `clientNode.enable()` — brings a disabled, reachable node back online | +| — (read) | `clientNode.state.network.isDisabled` | + +`disable()` sets the persisted `network.isDisabled` flag, so a node stays disabled across controller +restarts. This is the "this node is intentionally off" state — a seasonal device (a christmas-light plug, a +pool-pump socket) you want the controller to leave alone, and the bulk connect to skip, until you +`enable()` it again. For a transient disconnect that keeps the node enabled for on-demand reconnect, use +`clientNode.stop()` instead — it drops the connection without persisting a disabled flag. + +Two independent knobs, don't conflate them: per-node `disable()`/`enable()` is device *state* (seasonal +off, persisted); `NetworkServer.autoStartCommissionedPeers` (below) is a controller *policy* (do I +auto-connect at all) that leaves every node enabled. + +**Bulk "connect all nodes" is no longer a manual loop.** When the controller `ServerNode` comes online, +its `Peers` container automatically starts every commissioned peer that is not disabled — disabled peers +are skipped and stay offline until explicitly `enable()`d. So the legacy pattern + +```ts +// legacy: connect every commissioned node +for (const node of controller.getCommissionedNodes()) { + await controller.connectNode(node); +} +``` + +has no new-API equivalent — it happens automatically on controller start. To act on all peers explicitly +(e.g. disable all), iterate the peers directly: + +```ts +for (const peer of serverNode.peers.commissioned) { + await peer.disable(); +} +``` + +**Opting out of the automatic bulk connect.** The online-time bulk connect is gated by +`NetworkServer.autoStartCommissionedPeers` (default `true` = auto-connect commissioned peers on online). +Set it to `false` at `ServerNode.create` (`network: { autoStartCommissionedPeers: false }`) for manual / +on-demand control — the node then connects **no** peer on online. With it off, a commissioned peer is only +usable **after you explicitly bring it online**: call `clientNode.enable()` for a peer that is `isDisabled` +(this clears the flag and starts it — `start()` alone throws `UncommissionedError` on a disabled peer), or +`clientNode.start()` for an already-enabled peer. This is the new-API replacement for the legacy +`pairedNode.connect()` call: there is no implicit connect, you `enable()`/`start()` on demand. Enabling a +peer while `autoStartCommissionedPeers` is `false` does **not** cause it to auto-connect on the next online +— the sweep is off regardless of `isDisabled`. The nodejs-shell uses `false` so a debug session connects +only the peers you ask for. + +--- + +## Connection state + +`ClientNode` now exposes the 4-state connection status on `lifecycle`, replacing PairedNode's `NodeStates`: + +| Legacy (`PairedNode`) | New (`clientNode.lifecycle`) | +| --- | --- | +| `node.state` (`NodeStates`) | `lifecycle.connectionState` (`NodeConnectionState`) | +| `node.events.stateChanged` | `lifecycle.connectionStateChanged` | +| — | `lifecycle.isConnected` (convenience) | + +`NodeConnectionState` uses the same numeric values as legacy `NodeStates` +(`Connected=0, Disconnected=1, Reconnecting=2, WaitingForDeviceDiscovery=3`), so a consumer migrates by +type-swap. Semantics: `Connected` = live subscription; `Disconnected` = node stopped / not started / +disabled; `Reconnecting` = started and (re-)establishing; `WaitingForDeviceDiscovery` = peer appears +offline (establishment past the MRP budget, or a missed ICD check-in for registered LIT ICDs). The +2-state `lifecycle.isOnline` still exists for simple online/offline needs. + +--- + +## Accessing clusters + +The paradigm-shift table gives the mapping; this is the full resolve-and-access pattern (works for any +cluster, including one you only know by numeric id). + +Resolve the behavior for a cluster on an endpoint: + +```ts +import { ClusterId } from "@matter/types"; + +if (!node.lifecycle.isSeeded) await node.lifecycle.seeded; +const endpoint = node.endpoints.for(endpointId); +const behaviorType = endpoint.behaviors.forCluster(ClusterId(clusterId)); // by id — or import the XxxClient type +``` + +Read cached state, write, invoke: + +```ts +const value = endpoint.stateOf(behaviorType.id)[attributeName]; // read (cached) +await endpoint.setStateOf(behaviorType.id, { [attributeName]: value }); // write +const result = await endpoint.commandsOf(behaviorType.id)[commandName](request); // invoke +``` + +Check whether the device actually exposes an element before touching it (replaces the legacy +"cluster not supported" guards): + +```ts +endpoint.behaviors.elementsOf(behaviorType).attributes.has(attributeName); +endpoint.behaviors.elementsOf(behaviorType).commands.has(commandName); +``` + +Live (un-cached) read via the interaction protocol — for a fresh value, or a cluster the node holds no +behavior for (the shell's `--remote` / by-id reads): + +```ts +import { Read } from "@matter/protocol"; + +for await (const chunk of node.interaction.read(Read({ attributes: [{ endpointId, clusterId, attributeId }], fabricFilter: true }))) { + for await (const report of chunk) { + if (report.kind === "attr-value") { + // report.path, report.value + } + } +} +``` + +(Events: pass `{ events: [...] }` and match `report.kind === "event-value"`.) + +Name ↔ id conversion uses `ClusterLookup` from `@matter/types`, e.g. +`ClusterLookup.attributeName(ClusterId(clusterId), attributeId, node.matter)` (also `attributeId`, `eventId`, +`eventName`, `commandId`, `commandName`; pass `node.matter` so custom clusters resolve). + +When you hold the typed client behavior, invoke per endpoint directly — e.g. Identify on every endpoint that +supports it: + +```ts +import { IdentifyClient } from "@matter/node/behaviors/identify"; + +for (const endpoint of node.endpoints) { + if (!endpoint.behaviors.has(IdentifyClient)) continue; + await endpoint.act(agent => agent.get(IdentifyClient).identify({ identifyTime: 10 })); +} +``` + +--- + +## Attribute / event changes (the `PairedNode.events` bus) + +The default replacement for PairedNode's flat per-node event bus is the node-level +**`ChangeNotificationService`** — one aggregate change stream covering every endpoint on the controller +node **and all of its peers**. This is what a consumer feeding a push/sync pipeline (dashboards, bridges) +should use; PairedNode's flat events were themselves implemented on top of it. + +```ts +import { ChangeNotificationService } from "@matter/node"; + +const changes = serverNode.env.get(ChangeNotificationService); +changes.change.on(change => { + switch (change.kind) { + case "update": // attribute/state change + // change.endpoint, change.behavior, change.properties?, change.version + break; + case "event": // Matter event occurred + // change.endpoint, change.behavior, change.event, change.payload?, change.number, change.timestamp, change.priority + break; + case "delete": // endpoint/node removed — drop its data subtree + // change.endpoint + break; + } +}); +``` + +Mapping from the legacy flat bus: + +| Legacy `node.events.*` | New (`ChangeNotificationService.change`, unless noted) | +| --- | --- | +| `attributeChanged` | `change.kind === "update"` (`endpoint` + `behavior` + `properties`) | +| `eventTriggered` | `change.kind === "event"` (`endpoint` + `behavior` + `event` + occurrence) | +| `deviceInformationChanged` | `"update"` on `BasicInformationClient` | +| `nodeEndpointRemoved` / node removed | `change.kind === "delete"` | +| `structureChanged` / `nodeEndpointAdded` | `clientNode.lifecycle.changed` + `clientNode.endpoints` (structure signal; `ChangeNotificationService` reports deletions, additions surface as endpoints appear) | +| `connectionAlive` | `NetworkClient` `subscriptionAlive`, or `lifecycle.connectionStateChanged` (connection, not a data change) | +| `stateChanged` | `lifecycle.connectionStateChanged` (see Connection state) | +| `initialized` / init-done flags | `lifecycle.isSeeded` / `seeded` (see "Is the node ready?") | + +For a single specific value you can instead `reactTo` that behavior's `$Changed` (or the behavior's +own event) directly — fault-isolated and torn down with the behavior. That is the exception, not the +common case; reach for `ChangeNotificationService` when you need the whole node/peer change feed. + +### Verified shell idiom: node-wide diagnostic logging (all peers) + +The legacy per-connect diagnostic callbacks (`attributeChangedCallback` / `eventTriggeredCallback` / +`stateInformationCallback`, passed into every `PairedNode.connect`) map onto **one** node-wide listener, +registered once, rather than a callback bundle per connect. The shell wires this in +`MatterNode` (`installDiagnosticLogging`, `util/diagnosticLogging.ts`): + +```ts +import { ChangeNotificationService, ClusterBehavior, NodeConnectionState } from "@matter/node"; + +const observers = new ObserverGroup(); + +// attributeChangedCallback / eventTriggeredCallback: one aggregate stream for every peer. Map each change +// back to its peer via the owner chain (a peer node is its own root endpoint). +observers.on(serverNode.env.get(ChangeNotificationService).change, change => { + const peer = serverNode.peers.commissioned.find(p => ownedBy(change.endpoint, p)); + if (peer === undefined) return; // a change on the controller node itself, not a peer + switch (change.kind) { + case "update": { + if (!ClusterBehavior.is(change.behavior)) break; + const state = change.endpoint.stateOf(change.behavior.id); + const changed = change.properties?.reduce((o, n) => ((o[n] = state[n]), o), {}) ?? state; + // peer.peerAddress.nodeId, change.endpoint.number, change.behavior.cluster.id, change.version + break; + } + case "event": /* change.behavior/event/number/timestamp/priority/payload */ break; + case "delete": /* change.endpoint removed */ break; + } +}); + +// stateInformationCallback: each peer's connection-state transitions, for existing and future peers. +const watch = peer => + observers.on(peer.lifecycle.connectionStateChanged, state => { + /* NodeConnectionState.Connected / Disconnected / Reconnecting / WaitingForDeviceDiscovery */ + }); +for (const peer of serverNode.peers.commissioned) watch(peer); +observers.on(serverNode.peers.added, watch); +``` + +To watch a **single** node instead (e.g. the shell `subscribe` command establishing an on-demand +subscription), filter the same aggregate stream to that node's endpoint subtree with `ownedBy`, and use +`node.eventsOf(NetworkClient).subscriptionStatusChanged` for its subscription liveness. Establishing the +sustained subscription is just a state write — `await node.set({ network: { autoSubscribe: true } })` — after +which changes flow to the node-wide listener above. + +--- + +## Commissioning windows + +Available on both `ClientNode` and its `CommissioningClient` behavior: + +- `openBasicCommissioningWindow(commissioningTimeout?: Duration): Promise` (default `Seconds(900)`) +- `openEnhancedCommissioningWindow(commissioningTimeout?: Duration): Promise<{ manualPairingCode: string; qrPairingCode: string }>` + +Both revoke any stale window first (tolerating `WindowNotOpen`). The enhanced window generates the +discriminator/passcode/salt/iterations and PAKE verifier and returns the pairing codes; basic throws +`ImplementationError` if the peer's `AdministratorCommissioning` cluster lacks the `Basic` feature. + +--- + +## "Is the node ready?" + +Legacy `initialized` / `localInitializationDone` / `remoteInitializationDone` → `clientNode.lifecycle.isSeeded` +(the peer's structure has been read: BasicInformation present and more than just the root endpoint), with +`lifecycle.seeded` emitting once on the false→true transition. + +`await lifecycle.seeded` never resolves for a peer that is offline, so bound the wait — legacy +`await node.events.initialized` had the same hang. Race it against a timeout and abort the command on +expiry (the shell's `awaitSeeded` helper, `util/awaitSeeded.ts`): + +```ts +if (clientNode.lifecycle.isSeeded) return true; +const observers = new ObserverGroup(); +const sleep = Time.sleep("awaitSeeded", Seconds(60)); +try { + const seeded = new Promise(resolve => observers.on(clientNode.lifecycle.seeded, () => resolve())); + return await Promise.race([seeded.then(() => true), sleep.then(() => false)]); +} finally { + sleep.cancel(); + observers.close(); +} +``` + +--- + +## Sessions + +Legacy `controller.getActiveSessionInformation()` → the controller node's `SessionsBehavior` state: + +```ts +import { SessionsBehavior } from "@matter/node"; + +const sessions = Object.values(serverNode.stateOf(SessionsBehavior).sessions); +``` + +Each `SessionsBehavior.Session` is +`{ name, nodeId, peerNodeId, fabric?, isPeerActive, lastInteractionTimestamp, lastActiveTimestamp, numberOfActiveSubscriptions }`. +For live changes, react to `serverNode.eventsOf(SessionsBehavior).opened` / `.closed` / `.subscriptionsChanged`. + +--- + +## Controller-side ICD + +A commissioned peer exposing an `IcdManagement` cluster gets an `IcdClient` behavior injected automatically. +Legacy per-node ICD state and callbacks map onto it: + +```ts +import { IcdClient, IcdMultiAdminError } from "@matter/node"; +import { Millis } from "@matter/general"; + +if (!node.behaviors.has(IcdClient)) return; // not an ICD device + +const { registered, available, lastCheckInReceivedAt } = node.stateOf(IcdClient); +const awake = await node.act(agent => agent.get(IcdClient).awake); +const supportsLit = IcdClient.litSupported(node); + +// operations: +await node.act(agent => agent.get(IcdClient).register({ clientType, monitoredSubject, allowMultiAdmin, ignoredVendors })); +await node.act(agent => agent.get(IcdClient).unregister()); +await node.act(agent => agent.get(IcdClient).forget()); // local-only forget, for an unreachable LIT peer +const promised = await node.act(agent => agent.get(IcdClient).stayActive(Millis(30000))); + +// events: +const events = node.eventsOf(IcdClient); +// registered, unregistered, checkedIn, checkInMissed, keyRefreshed, available$Changed +``` + +`register()` throws `IcdMultiAdminError` (carrying `adminVendorIds`) when other-vendor admins are present, +unless `allowMultiAdmin` is set. Connection status uses `NodeConnectionState` (see Connection state). + +--- + +## FAQ + +**Do I still get a `PairedNode` anywhere?** No. The controller's commissioned peers are `ClientNode` +instances in `serverNode.peers`; `PairedNode` (and `CommissioningController`) are deprecated and removed in +0.19. + +**How do I get a commissioned peer by node id?** `serverNode.peers.get(nodeId)` (or `.forAddress(address)`) +returns the `ClientNode`. Enumerate with `serverNode.peers.commissioned`. + +**How do I read/invoke a cluster when I only have a numeric cluster id?** `endpoint.stateOf(behaviors.forCluster(id))` +/ `commandsOf(...)`. For name↔id conversion use the `ClusterLookup` namespace from `@matter/types` +(`ClusterLookup.attributeId` / `attributeName` / `eventId` / `eventName` / `commandId` / `commandName` — +each accepts an optional `MatterModel`, defaulting to the global model, so custom clusters resolve via +`clientNode.matter`). + +**Do I need to poll for reachability?** No. Read `clientNode.lifecycle.connectionState` (or `isConnected`) and +react to `connectionStateChanged`; drop any reconnect timers / availability debounce. + +**How do I add a node that is already commissioned on the fabric but not known locally?** +`await serverNode.peers.forAddress(fabric.addressOf(nodeId))` (build the address from a fabric obtained via +`FabricAuthority`), then connect it like any other peer. This is the new-API equivalent of legacy +`getNode(nodeId, allowUnknown)` for a peer commissioned by another controller on the shared fabric. + +**How do I get the `ClientNode`s to work with?** `await serverNode.start()` then read `serverNode.peers.commissioned` +(the commissioned `ClientNode`s). Enabling a peer is `clientNode.enable()` (disabled) / `clientNode.start()` +(already enabled). There is no `connectAndGetNodes` bulk call — enumerate `peers.commissioned` and enable as +needed (bulk connect happens automatically on controller start unless `autoStartCommissionedPeers` is `false`; see above). + +**How do I subscribe to a node's changes?** Register a listener on +`serverNode.env.get(ChangeNotificationService).change` and filter to the node's endpoint subtree (see the +verified shell idiom above), then `await clientNode.set({ network: { autoSubscribe: true } })`. Do **not** look +for a `subscribeAllAttributesAndEvents` — the sustained subscription is state-driven. + +**Where did the per-connect diagnostic callbacks go?** There is no per-connect callback bundle. Wire it once, +node-wide: a single `ChangeNotificationService.change` listener (attribute/event/structure changes for every +peer) plus, per peer, `lifecycle.connectionStateChanged` (attach to `serverNode.peers.commissioned` now and to +`serverNode.peers.added` for future peers) — one registration set instead of callbacks threaded through each +connect call. See the node-wide diagnostic-logging idiom above. + +**How do I wait until a node's structure is usable?** `if (!clientNode.lifecycle.isSeeded) await clientNode.lifecycle.seeded;` +before reading its endpoints/behaviors. For an offline peer this can wait indefinitely, so bound it (timeout / +abort on `WaitingForDeviceDiscovery`) if the caller must not hang. + +**I'm upgrading directly from a pre-0.13 (pre-`ServerNode`) install — is my controller storage migrated?** +The legacy `CommissioningController` carried a one-time migration of the old on-disk layout +(`credentials` → `certificates`/`fabrics`, plus per-node data) into the `ServerNode` stores. The new API has +no equivalent hook. Anyone who has already run a 0.13+ (`ServerNode`-based) build is unaffected — that storage +is already in the new format and is reused as-is via `FabricAuthority.defaultFabric`. Only a *direct* jump from +a pre-0.13 layout to 0.18 needs a manual re-commission (or an interim run on a 0.13–0.17 build to migrate). diff --git a/packages/matter.js/src/CommissioningController.ts b/packages/matter.js/src/CommissioningController.ts index 58e8b2aad1..ef9f690641 100644 --- a/packages/matter.js/src/CommissioningController.ts +++ b/packages/matter.js/src/CommissioningController.ts @@ -77,6 +77,7 @@ function discoveryKey( return JSON.stringify({ id: identifierData, caps: discoveryCapabilities }); } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export async function runDiscoverCommissionableDevices( node: ServerNode, identifierData: CommissionableDeviceIdentifiers, @@ -113,6 +114,7 @@ export async function runDiscoverCommissionableDevices( } } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export function cancelDiscoverCommissionableDevices( identifierData: CommissionableDeviceIdentifiers, discoveryCapabilities: TypeFromPartialBitSchema | undefined, @@ -125,6 +127,9 @@ export function cancelDiscoverCommissionableDevices( // TODO decline using setRoot*Cluster // TODO Decline cluster access after announced/paired +/** + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export type ControllerEnvironmentOptions = { /** * Environment to register the node with on start() @@ -139,6 +144,8 @@ export type ControllerEnvironmentOptions = { /** * Constructor options for the CommissioningController class + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export type CommissioningControllerOptions = CommissioningControllerNodeOptions & { /** @@ -238,6 +245,8 @@ export type CommissioningControllerOptions = CommissioningControllerNodeOptions /** * Configuration for performing discovery + commissioning in one step. * Kept in the legacy matter.js package; new code uses {@link CommissioningDiscovery.Options} directly. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface DiscoveryAndCommissioningOptions extends CommissioningOptions { /** Discovery related options. */ @@ -275,14 +284,22 @@ export interface DiscoveryAndCommissioningOptions extends CommissioningOptions { }; } -/** Options needed to commission a new node */ +/** + * Options needed to commission a new node + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export type NodeCommissioningOptions = CommissioningControllerNodeOptions & { commissioning: Omit; discovery: DiscoveryAndCommissioningOptions["discovery"]; passcode: number; }; -/** Controller class to commission and connect multiple nodes into one fabric. */ +/** + * Controller class to commission and connect multiple nodes into one fabric. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export class CommissioningController { #crypto: Crypto; #started = false; @@ -308,6 +325,7 @@ export class CommissioningController { * Creates a new CommissioningController instance * * @param options The options for the CommissioningController + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor(options: CommissioningControllerOptions) { if (options.environment === undefined) { @@ -323,7 +341,10 @@ export class CommissioningController { this.#crypto.reportUsage(); } - /** Returns the controller node instance. Throws an error when called before start() or after close(). */ + /** + * Returns the controller node instance. Throws an error when called before start() or after close(). + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get node(): ServerNode { return this.#assertControllerIsStarted().node; } @@ -331,20 +352,28 @@ export class CommissioningController { /** * Returns the OTA provider endpoint on the controller node, if enabled and controller node was started. * Else throws an error. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get otaProvider(): Endpoint { return this.#assertControllerIsStarted().node.endpoints.for("ota-provider") as Endpoint; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get crypto() { return this.#crypto; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get nodeId() { return this.#controllerInstance?.nodeId; } - /** Returns the configuration data needed to create a PASE commissioner, e.g. in a mobile app. */ + /** + * Returns the configuration data needed to create a PASE commissioner, e.g. in a mobile app. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get paseCommissionerConfig() { const controller = this.#assertControllerIsStarted( "The CommissioningController needs to be started to get the PASE commissioner data.", @@ -434,6 +463,8 @@ export class CommissioningController { /** * Commissions/Pairs a new device into the controller fabric. The method returns the NodeId of the commissioned * node on success. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async commissionNode( nodeOptions: NodeCommissioningOptions, @@ -481,6 +512,7 @@ export class CommissioningController { return nodeId; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ connectPaseChannel(nodeOptions: NodeCommissioningOptions): Promise { const controller = this.#assertControllerIsStarted(); @@ -491,13 +523,18 @@ export class CommissioningController { * Completes the commissioning process for a node when the initial commissioning process was done by a PASE * commissioner. This method should be called to discover the device operational and complete the commissioning * process. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ completeCommissioningForNode(peerNodeId: NodeId, discoveryData?: DiscoveryData) { const controller = this.#assertControllerIsStarted(); return controller.completeCommissioning(peerNodeId, discoveryData); } - /** Check if a given node id is commissioned on this controller. */ + /** + * Check if a given node id is commissioned on this controller. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ isNodeCommissioned(nodeId: NodeId) { const controller = this.#assertControllerIsStarted(); return controller.getCommissionedNodes().includes(nodeId) ?? false; @@ -519,6 +556,8 @@ export class CommissioningController { * use this in case of an error as last option. * If this method is used the state of the PairedNode instance might be out of sync, so the PairedNode instance * should be disconnected first. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async removeNode(nodeId: NodeId, tryDecommissioning = true) { const controller = this.#assertControllerIsStarted(); @@ -577,6 +616,8 @@ export class CommissioningController { /** * Returns the PairedNode instance for a given NodeId. The instance is initialized without auto-connect if not yet * created. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getNode(nodeId: NodeId, allowUnknownNode = false) { const existingNode = this.#pairedNodeForNodeId(nodeId); @@ -700,7 +741,10 @@ export class CommissioningController { return Array.from(this.#initializedNodes.values()); } - /** Returns true if t least one node is commissioned/paired with this controller instance. */ + /** + * Returns true if at least one node is commissioned/paired with this controller instance. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ isCommissioned() { const controller = this.#assertControllerIsStarted(); @@ -710,6 +754,8 @@ export class CommissioningController { /** * Creates and Return a new InteractionClient to communicate with a node. This is mainly used internally and should * not be used directly. See the PairedNode class for the public API. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async createInteractionClient( nodeIdOrSession: NodeId | SecureSession, @@ -747,14 +793,20 @@ export class CommissioningController { return this.#pairedNodeForNodeId(nodeId); } - /** Returns an array with the NodeIds of all commissioned nodes. */ + /** + * Returns an array with the NodeIds of all commissioned nodes. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ getCommissionedNodes() { const controller = this.#assertControllerIsStarted(); return controller.getCommissionedNodes() ?? []; } - /** Returns an array with all commissioned NodeIds and their metadata. */ + /** + * Returns an array with all commissioned NodeIds and their metadata. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ getCommissionedNodesDetails(): PairedNodeDetails[] { const controller = this.#assertControllerIsStarted(); @@ -764,6 +816,8 @@ export class CommissioningController { /** * Disconnects all connected nodes and closes the network connections and other resources of the controller. * You can use "start()" to restart the controller after closing it. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async close() { this.#observers.close(); @@ -779,7 +833,10 @@ export class CommissioningController { this.#started = false; } - /** Return the port used by the controller for the UDP interface. */ + /** + * Return the port used by the controller for the UDP interface. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ getPort(): number | undefined { return this.#options.localPort; } @@ -797,12 +854,15 @@ export class CommissioningController { this.#ipv4Disabled = ipv4Disabled; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get env() { return this.#environment; } /** * Initialize the controller and initialize and connect to all commissioned nodes if autoConnect is not set to false. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async start() { if (this.#ipv4Disabled === undefined) { @@ -850,6 +910,8 @@ export class CommissioningController { /** * Cancels the discovery process for commissionable devices started with discoverCommissionableDevices(). + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ cancelCommissionableDeviceDiscovery( identifierData: CommissionableDeviceIdentifiers, @@ -862,6 +924,8 @@ export class CommissioningController { * Starts to discover commissionable devices. * The promise will be fulfilled after the provided timeout or when the discovery is stopped via * cancelCommissionableDeviceDiscovery(). The discoveredCallback will be called for each discovered device. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async discoverCommissionableDevices( identifierData: CommissionableDeviceIdentifiers, @@ -882,6 +946,8 @@ export class CommissioningController { /** * Use this method to reset the Controller storage. The method can only be called if the controller is stopped and * will remove all commissioning data and paired nodes from the controller. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async resetStorage() { if (this.#started) { @@ -892,7 +958,10 @@ export class CommissioningController { await this.node.erase(); // TODO check if that's correct } - /** Returns active session information for all connected nodes. */ + /** + * Returns active session information for all connected nodes. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ getActiveSessionInformation(): ActiveSessionInformation[] { return this.#controllerInstance?.getActiveSessionInformation() ?? []; } @@ -942,6 +1011,8 @@ export class CommissioningController { /** * Updates the fabric label for the controller and all connected nodes. * The label is used to identify the controller and all connected nodes in the fabric. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async updateFabricLabel(label: string) { const controller = this.#assertControllerIsStarted(); @@ -992,11 +1063,13 @@ export class CommissioningController { } } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get groups(): FabricGroups { const controllerInstance = this.#assertControllerIsStarted(); return controllerInstance.fabric.groups; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get fabric(): Fabric { const controllerInstance = this.#assertControllerIsStarted(); return controllerInstance.fabric; diff --git a/packages/matter.js/src/ControllerStore.ts b/packages/matter.js/src/ControllerStore.ts index c79286e210..a7fa5336da 100644 --- a/packages/matter.js/src/ControllerStore.ts +++ b/packages/matter.js/src/ControllerStore.ts @@ -8,6 +8,8 @@ import { ImplementationError, MaybePromise, StorageContext, StorageManager } fro /** * Non-volatile state management for a {@link ControllerNode}. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class ControllerStore implements ControllerStoreInterface { #storageManager?: StorageManager; @@ -19,6 +21,8 @@ export class ControllerStore implements ControllerStoreInterface { * Create a new store. * * TODO - implement conversion from 0.7 format so people can change API seamlessly + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor(nodeId: string, storageManager: StorageManager) { if (nodeId === undefined) { @@ -28,16 +32,19 @@ export class ControllerStore implements ControllerStoreInterface { this.#storageManager = storageManager; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async erase() { await this.#sessionStorage?.clearAll(); await this.#caStorage?.clearAll(); await this.#nodesStorage?.clearAll(); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async close() { // nothing to do, we do not own anything } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get sessionStorage() { if (!this.#sessionStorage) { this.#sessionStorage = this.storage.createContext("sessions"); @@ -45,6 +52,7 @@ export class ControllerStore implements ControllerStoreInterface { return this.#sessionStorage; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get caStorage() { if (!this.#caStorage) { this.#caStorage = this.storage.createContext("credentials"); @@ -52,6 +60,7 @@ export class ControllerStore implements ControllerStoreInterface { return this.#caStorage; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get nodesStorage() { if (this.#nodesStorage === undefined) { this.#nodesStorage = this.storage.createContext("nodes"); @@ -59,10 +68,12 @@ export class ControllerStore implements ControllerStoreInterface { return this.#nodesStorage; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get fabricStorage() { return this.caStorage; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get storage() { if (this.#storageManager === undefined) { throw new ImplementationError("Node storage accessed prior to initialization"); @@ -70,17 +81,26 @@ export class ControllerStore implements ControllerStoreInterface { return this.#storageManager; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async clientNodeStore(nodeId: string) { return this.storage.createContext(`node-${nodeId}`); } } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export abstract class ControllerStoreInterface { + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ abstract erase(): Promise; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ abstract close(): Promise; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ abstract get sessionStorage(): StorageContext; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ abstract get caStorage(): StorageContext; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ abstract get nodesStorage(): StorageContext; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ abstract get fabricStorage(): StorageContext; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ abstract clientNodeStore(nodeId: string): MaybePromise; } diff --git a/packages/matter.js/src/PaseCommissioner.ts b/packages/matter.js/src/PaseCommissioner.ts index 73c82dc174..d23f6c16d3 100644 --- a/packages/matter.js/src/PaseCommissioner.ts +++ b/packages/matter.js/src/PaseCommissioner.ts @@ -40,6 +40,8 @@ type PaseCommissionerOptions = Omit, @@ -137,6 +151,7 @@ export class PaseCommissioner { cancelDiscoverCommissionableDevices(identifierData, discoveryCapabilities, this.#activeDiscoveries); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async discoverCommissionableDevices( identifierData: CommissionableDeviceIdentifiers, discoveryCapabilities?: TypeFromPartialBitSchema, diff --git a/packages/matter.js/src/cluster/client/AttributeClient.ts b/packages/matter.js/src/cluster/client/AttributeClient.ts index c2f9bd8592..c6e418bc6d 100644 --- a/packages/matter.js/src/cluster/client/AttributeClient.ts +++ b/packages/matter.js/src/cluster/client/AttributeClient.ts @@ -12,6 +12,8 @@ import { InteractionClient } from "./InteractionClient.js"; /** * Factory function to create an AttributeClient for a given attribute. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export function createAttributeClient( attribute: ClusterType.Attribute, @@ -33,6 +35,8 @@ export function createAttributeClient( /** * General class for AttributeClients + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class AttributeClient { readonly #isWritable: boolean; @@ -43,6 +47,7 @@ export class AttributeClient { readonly id: AttributeId; readonly #interactionClient: InteractionClient; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor( readonly attribute: ClusterType.Attribute, readonly name: string, @@ -62,6 +67,8 @@ export class AttributeClient { /** * Set the value of the attribute. When dataVersion parameter is provided the value is only set when the * cluster dataVersion of the server matches. If it does not match it is rejected with an Error. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async set(value: T, dataVersion?: number) { if (!this.#isWritable) throw new ImplementationError(`Attribute ${this.name} is not writable`); @@ -109,10 +116,12 @@ export class AttributeClient { }); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get fabricScoped() { return this.#isFabricScoped; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getLocal() { if (this.endpointId === undefined) { throw new ImplementationError(`Cannot read attribute ${this.name} without endpointId.`); @@ -130,6 +139,8 @@ export class AttributeClient { * - `true` forces a remote read * - `false` forces a local read, return undefined if no value is available * - `undefined` returns local values if available or if the read is fabric filtered, otherwise remote read + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async get(requestFromRemote?: boolean, isFabricFiltered = true) { if (this.endpointId === undefined) { @@ -149,7 +160,10 @@ export class AttributeClient { }); } - /** Subscribe to the attribute. */ + /** + * Subscribe to the attribute. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ async subscribe( minIntervalFloorSeconds: number, maxIntervalCeilingSeconds: number, @@ -182,12 +196,18 @@ export class AttributeClient { this.#listeners.forEach(listener => listener(value)); } - /** Add a listener to the attribute. */ + /** + * Add a listener to the attribute. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ addListener(listener: (newValue: T) => void) { this.#listeners.push(listener); } - /** Remove a listener from the attribute. */ + /** + * Remove a listener from the attribute. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ removeListener(listener: (newValue: T) => void) { const entryIndex = this.#listeners.indexOf(listener); if (entryIndex !== -1) { @@ -198,11 +218,15 @@ export class AttributeClient { /** * Special AttributeClient class to allow identifying attributes that are supported because reported by the Devices. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class SupportedAttributeClient extends AttributeClient {} /** * Special AttributeClient class to allow identifying attributes that are supported because reported by the Devices, * but the contained attribute is unknown. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class UnknownSupportedAttributeClient extends SupportedAttributeClient {} diff --git a/packages/matter.js/src/cluster/client/ClusterClient.ts b/packages/matter.js/src/cluster/client/ClusterClient.ts index 29ad3b4c8f..71b4723a4c 100644 --- a/packages/matter.js/src/cluster/client/ClusterClient.ts +++ b/packages/matter.js/src/cluster/client/ClusterClient.ts @@ -28,6 +28,7 @@ import { InteractionClient } from "./InteractionClient.js"; const logger = Logger.get("ClusterClient"); +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export function GroupClusterClient( clusterDef: N, interactionClient: InteractionClient, @@ -46,6 +47,7 @@ export function GroupClusterClient( return ClusterClient(clusterDef as any, undefined, interactionClient, globalAttributeValues) as any; } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export function ClusterClient( clusterDef: N, endpointId: EndpointNumber | undefined, diff --git a/packages/matter.js/src/cluster/client/ClusterClientTypes.ts b/packages/matter.js/src/cluster/client/ClusterClientTypes.ts index 53a2808576..91735857f0 100644 --- a/packages/matter.js/src/cluster/client/ClusterClientTypes.ts +++ b/packages/matter.js/src/cluster/client/ClusterClientTypes.ts @@ -18,34 +18,56 @@ import { } from "@matter/types"; import { DecodedEventData } from "./DecodedDataReport.js"; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface AttributeClientObj { + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly id: AttributeId; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly attribute: ClusterType.Attribute; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly name: string; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly endpointId: EndpointNumber | undefined; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly clusterId: ClusterId; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly fabricScoped: boolean; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ set(value: T, dataVersion?: number): Promise; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getLocal(): T | undefined; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get(requestFromRemote?: boolean, isFabricFiltered?: boolean): Promise; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ subscribe( minIntervalFloorSeconds: number, maxIntervalCeilingSeconds: number, knownDataVersion?: number, isFabricFiltered?: boolean, ): Promise<{ maxInterval: number }>; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ update(value: T): void; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ addListener(listener: (newValue: T) => void): void; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ removeListener(listener: (newValue: T) => void): void; } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface EventClientObj { + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly id: EventId; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly event: ClusterType.Event; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly name: string; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly endpointId: EndpointNumber | undefined; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly clusterId: ClusterId; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get(minimumEventNumber?: EventNumber, isFabricFiltered?: boolean): Promise[] | undefined>; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ subscribe( minIntervalFloorSeconds: Duration, maxIntervalCeilingSeconds: Duration, @@ -53,8 +75,11 @@ export interface EventClientObj { minimumEventNumber?: EventNumber, isFabricFiltered?: boolean, ): Promise<{ maxInterval: number }>; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ update(newEvent: DecodedEventData): void; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ addListener(listener: (newValue: DecodedEventData) => void): void; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ removeListener(listener: (newValue: DecodedEventData) => void): void; } @@ -62,23 +87,35 @@ export interface EventClientObj { /** * Command options shared by all client command invocations. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface ClientCommandOptions { - /** Send this command as a timed request also when not required. Default timeout are 10 seconds. */ + /** + * Send this command as a timed request also when not required. Default timeout are 10 seconds. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ asTimedRequest?: boolean; - /** Override the request timeout when the command is sent as times request. Default are 10s. */ + /** + * Override the request timeout when the command is sent as times request. Default are 10s. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ timedRequestTimeout?: Duration; /** * Expected processing time on the device side for this command. * useExtendedFailSafeMessageResponseTimeout is ignored if this value is set. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ expectedProcessingTime?: Duration; /** * Use the extended fail-safe message response timeout of 30 seconds. Use this for all commands * executed during an activated FailSafe context! + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ useExtendedFailSafeMessageResponseTimeout?: boolean; } @@ -175,6 +212,8 @@ type ClientCommandsRecord = ClientCommands & Record = { id: ClusterId; @@ -209,6 +248,7 @@ export type ClusterClientObj = { ClientEventSubscribers & ClientEventListeners; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export type ClusterClientObjInternal = ClusterClientObj & { /** * Trigger an attribute update. This is mainly used internally and not needed to be called by the user. diff --git a/packages/matter.js/src/cluster/client/DecodedDataReport.ts b/packages/matter.js/src/cluster/client/DecodedDataReport.ts index 6d8a42e325..10271017a5 100644 --- a/packages/matter.js/src/cluster/client/DecodedDataReport.ts +++ b/packages/matter.js/src/cluster/client/DecodedDataReport.ts @@ -23,6 +23,7 @@ import { const logger = Logger.get("DecodedDataReport"); +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export type DecodedAttributeReportEntry = { path: { nodeId?: NodeId; @@ -33,18 +34,25 @@ export type DecodedAttributeReportEntry = { }; }; -/** Represents a fully qualified and decoded attribute value from a received DataReport. */ +/** + * Represents a fully qualified and decoded attribute value from a received DataReport. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export type DecodedAttributeReportValue = DecodedAttributeReportEntry & { version: number; value: T; }; -/** Represents a fully qualified and decoded attribute status from a received DataReport. */ +/** + * Represents a fully qualified and decoded attribute status from a received DataReport. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export type DecodedAttributeReportStatus = DecodedAttributeReportEntry & { status: Status; clusterStatus?: number; }; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export type DecodedEventData = { eventNumber: EventNumber; priority: Priority; @@ -55,6 +63,7 @@ export type DecodedEventData = { data?: T; }; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export type DecodedEventReportEntry = { path: { nodeId?: NodeId; @@ -65,12 +74,18 @@ export type DecodedEventReportEntry = { }; }; -/** Represents a fully qualified and decoded event value from a received DataReport. */ +/** + * Represents a fully qualified and decoded event value from a received DataReport. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export type DecodedEventReportValue = DecodedEventReportEntry & { events: DecodedEventData[]; }; -/** Represents a fully qualified and decoded event status from a received DataReport. */ +/** + * Represents a fully qualified and decoded event status from a received DataReport. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export type DecodedEventReportStatus = DecodedEventReportEntry & { status: Status; clusterStatus?: number; @@ -83,6 +98,8 @@ export type DecodedEventReportStatus = DecodedEventReportEntry & { * * Kept here in the matter.js package while the legacy `InteractionClient` / `ClusterClient` / `EventClient` / * `PairedNode` callbacks consume it. The modern path consumes `ReadResult.Chunk` directly via {@link InputChunk}. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface DecodedDataReport extends DataReport { attributeReports: DecodedAttributeReportValue[]; @@ -97,6 +114,8 @@ export interface DecodedDataReport extends DataReport { * span multiple incoming reports. * * For new code prefer consuming the chunk stream directly. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export async function decodeDataReport( report: DataReport, @@ -133,7 +152,10 @@ export async function decodeDataReport( }; } -/** Build a legacy {@link DecodedAttributeReportValue} from a streaming chunk. */ +/** + * Build a legacy {@link DecodedAttributeReportValue} from a streaming chunk. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export function toDecodedAttributeReportValue(chunk: ReadResult.AttributeValue): DecodedAttributeReportValue { return { path: resolveAttributePath(chunk.path), @@ -142,7 +164,10 @@ export function toDecodedAttributeReportValue(chunk: ReadResult.AttributeValue): }; } -/** Build a legacy {@link DecodedAttributeReportStatus} from a streaming chunk. */ +/** + * Build a legacy {@link DecodedAttributeReportStatus} from a streaming chunk. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export function toDecodedAttributeReportStatus(chunk: ReadResult.AttributeStatus): DecodedAttributeReportStatus { return { path: resolveAttributePath(chunk.path), @@ -151,7 +176,10 @@ export function toDecodedAttributeReportStatus(chunk: ReadResult.AttributeStatus }; } -/** Build a legacy {@link DecodedEventReportValue} from a streaming chunk; forwards all four wire timestamp variants. */ +/** + * Build a legacy {@link DecodedEventReportValue} from a streaming chunk; forwards all four wire timestamp variants. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export function toDecodedEventReportValue(chunk: ReadResult.EventValue): DecodedEventReportValue { return { path: resolveEventPath(chunk.path), @@ -169,7 +197,10 @@ export function toDecodedEventReportValue(chunk: ReadResult.EventValue): Decoded }; } -/** Build a legacy {@link DecodedEventReportStatus} from a streaming chunk. */ +/** + * Build a legacy {@link DecodedEventReportStatus} from a streaming chunk. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ export function toDecodedEventReportStatus(chunk: ReadResult.EventStatus): DecodedEventReportStatus { return { path: resolveEventPath(chunk.path), diff --git a/packages/matter.js/src/cluster/client/EventClient.ts b/packages/matter.js/src/cluster/client/EventClient.ts index 9dbd177ae5..db1da09169 100644 --- a/packages/matter.js/src/cluster/client/EventClient.ts +++ b/packages/matter.js/src/cluster/client/EventClient.ts @@ -11,6 +11,8 @@ import { InteractionClient } from "./InteractionClient.js"; /** * Factory function to create an EventClient for a given event. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export function createEventClient( event: ClusterType.Event, @@ -24,12 +26,15 @@ export function createEventClient( /** * General class for EventClients + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class EventClient { readonly #listeners = new Array<(event: DecodedEventData) => void>(); readonly id: EventId; readonly #interactionClient: InteractionClient; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor( readonly event: ClusterType.Event, readonly name: string, @@ -41,6 +46,7 @@ export class EventClient { this.#interactionClient = interactionClient; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async get( minimumEventNumber?: EventNumber, isFabricFiltered?: boolean, @@ -57,6 +63,7 @@ export class EventClient { }); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async subscribe( minIntervalFloorSeconds: Duration, maxIntervalCeilingSeconds: Duration, @@ -80,16 +87,19 @@ export class EventClient { }); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ update(newEvent: DecodedEventData) { for (const listener of this.#listeners) { listener(newEvent); } } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ addListener(listener: (newValue: DecodedEventData) => void) { this.#listeners.push(listener); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ removeListener(listener: (newValue: DecodedEventData) => void) { const entryIndex = this.#listeners.indexOf(listener); if (entryIndex !== -1) { diff --git a/packages/matter.js/src/cluster/client/InteractionClient.ts b/packages/matter.js/src/cluster/client/InteractionClient.ts index 76733fc571..b556822f49 100644 --- a/packages/matter.js/src/cluster/client/InteractionClient.ts +++ b/packages/matter.js/src/cluster/client/InteractionClient.ts @@ -98,6 +98,8 @@ export enum NodeDiscoveryType { /** * Error when an unknown node is tried to be connected or any other action done with it. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class UnknownNodeError extends MatterError {} @@ -118,6 +120,7 @@ export interface DiscoveryOptions { * @deprecated these options are ignored */ export interface PeerConnectionOptions { + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ discoveryOptions?: DiscoveryOptions; /** @@ -136,18 +139,22 @@ function isAclOrExtensionPath(path: { clusterId: ClusterId; attributeId: Attribu return clusterId === AclClusterId && (attributeId === AclAttributeId || attributeId === AclExtensionAttributeId); } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export type ResponseDataReport = Omit< DecodedDataReport, "isNormalized" | "subscriptionId" | "interactionModelRevision" >; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface AttributeStatus { + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ path: { nodeId?: NodeId; endpointId?: EndpointNumber; clusterId?: ClusterId; attributeId?: AttributeId; }; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ status: Status; } @@ -161,36 +168,57 @@ type CommandRequest = type CommandResponse = C extends ClusterType.Command ? Awaited> : unknown; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export type InvokeOptions = { + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ endpointId?: EndpointNumber; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ clusterId: ClusterId; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ request: CommandRequest; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ command: C; - /** Send as timed request. If no timedRequestTimeoutMs is provided the default of 10s will be used. */ + /** + * Send as timed request. If no timedRequestTimeoutMs is provided the default of 10s will be used. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ asTimedRequest?: boolean; - /** Use this timeout and send the request as Timed Request. If this is specified the above parameter is implied. */ + /** + * Use this timeout and send the request as Timed Request. If this is specified the above parameter is implied. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ timedRequestTimeout?: Duration; /** * Expected processing time on the device side for this command. * useExtendedFailSafeMessageResponseTimeout is ignored if this value is set. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ expectedProcessingTime?: Duration; - /** Use an extended Message Response Timeout as defined for FailSafe cases which is 30s. */ + /** + * Use an extended Message Response Timeout as defined for FailSafe cases which is 30s. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ useExtendedFailSafeMessageResponseTimeout?: boolean; - /** Skip request data validation. Use this only when you know that your data is correct and validation would return an error. */ + /** + * Skip request data validation. Use this only when you know that your data is correct and validation would return an error. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ skipValidation?: boolean; }; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class InteractionClientProvider { readonly #owner: ServerNode; readonly #peers: PeerSet; readonly #clients = new PeerAddressMap(); + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor(owner: ServerNode) { this.#owner = owner; this.#peers = owner.env.get(PeerSet); @@ -198,10 +226,12 @@ export class InteractionClientProvider { this.#peers.disconnected.on(peer => this.#onPeerLoss(peer.address)); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get peers() { return this.#peers; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async connect( address: PeerAddress, _options: PeerConnectionOptions & { @@ -223,6 +253,8 @@ export class InteractionClientProvider { /** * Returns an InteractionClient for a session or PeerAddress which is not bound to a ClientNode Interactable * This should only be used for special cases. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async interactionClientFor(sessionOrAddress: SecureSession | PeerAddress): Promise { const exchangeProvider = this.#exchangeProviderFor(sessionOrAddress); @@ -237,6 +269,8 @@ export class InteractionClientProvider { /** * Returns an InteractionClient for a specific peer address and ensures that also a peer node exists. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getNodeInteractionClient(address: PeerAddress, _options: PeerConnectionOptions = {}) { let client = this.#clients.get(address); @@ -265,12 +299,14 @@ export class InteractionClientProvider { } } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class InteractionClient { readonly #address?: PeerAddress; readonly isGroupAddress: boolean; readonly #interaction: Interactable; readonly #exchanges: ExchangeProvider; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor(interaction: Interactable, exchanges: ExchangeProvider, address?: PeerAddress) { this.#address = address; this.#interaction = interaction; @@ -278,11 +314,12 @@ export class InteractionClient { this.isGroupAddress = address !== undefined ? PeerAddress.isGroup(address) : false; } - // TODO + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get interaction() { return this.#interaction; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get address() { if (this.#address === undefined) { throw new ImplementationError("This InteractionClient is not bound to a specific peer."); @@ -290,18 +327,22 @@ export class InteractionClient { return this.#address; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get maybeAddress() { return this.#address; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get isReconnectable() { return false; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get channelUpdated(): never { throw new ImplementationError("ExchangeProvider does not support channelUpdated"); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getAllAttributes( options: { dataVersionFilters?: { endpointId: EndpointNumber; clusterId: ClusterId; dataVersion: number }[]; @@ -321,6 +362,7 @@ export class InteractionClient { ).attributeReports; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getAllEvents( options: { eventFilters?: TypeFromSchema[]; @@ -335,6 +377,7 @@ export class InteractionClient { ).eventReports; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getAllAttributesAndEvents( options: { dataVersionFilters?: { @@ -361,6 +404,7 @@ export class InteractionClient { }); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getMultipleAttributes( options: { attributes?: { endpointId?: EndpointNumber; clusterId?: ClusterId; attributeId?: AttributeId }[]; @@ -376,6 +420,7 @@ export class InteractionClient { return (await this.getMultipleAttributesAndEvents(options)).attributeReports; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getMultipleAttributesAndStatus( options: { attributes?: { endpointId?: EndpointNumber; clusterId?: ClusterId; attributeId?: AttributeId }[]; @@ -395,6 +440,7 @@ export class InteractionClient { return { attributeData: attributeReports, attributeStatus }; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getMultipleEvents( options: { events?: { endpointId?: EndpointNumber; clusterId?: ClusterId; eventId?: EventId }[]; @@ -405,6 +451,7 @@ export class InteractionClient { return (await this.getMultipleAttributesAndEvents(options)).eventReports; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getMultipleEventsAndStatus( options: { events?: { endpointId?: EndpointNumber; clusterId?: ClusterId; eventId?: EventId }[]; @@ -416,6 +463,7 @@ export class InteractionClient { return { eventData: eventReports, eventStatus }; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getMultipleAttributesAndEvents( options: { attributes?: { endpointId?: EndpointNumber; clusterId?: ClusterId; attributeId?: AttributeId }[]; @@ -524,6 +572,7 @@ export class InteractionClient { }; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getStoredAttribute(options: { endpointId: EndpointNumber; clusterId: ClusterId; @@ -542,6 +591,7 @@ export class InteractionClient { return undefined; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getAttribute(options: { endpointId: EndpointNumber; clusterId: ClusterId; @@ -582,6 +632,7 @@ export class InteractionClient { return attributeReports[0]?.value as T | undefined; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async getEvent(options: { endpointId: EndpointNumber; clusterId: ClusterId; @@ -599,6 +650,7 @@ export class InteractionClient { return response?.eventReports[0]?.events as DecodedEventData[] | undefined; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async setAttribute(options: { attributeData: { endpointId?: EndpointNumber; @@ -637,6 +689,7 @@ export class InteractionClient { } } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async setMultipleAttributes(options: { attributes: { endpointId?: EndpointNumber; @@ -743,6 +796,7 @@ export class InteractionClient { .filter(({ status }) => status !== Status.Success); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async subscribeAttribute(options: { endpointId: EndpointNumber; clusterId: ClusterId; @@ -792,6 +846,7 @@ export class InteractionClient { return { maxInterval }; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async subscribeEvent(options: { endpointId: EndpointNumber; clusterId: ClusterId; @@ -846,6 +901,7 @@ export class InteractionClient { return { maxInterval }; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async subscribeAllAttributesAndEvents(options: { minIntervalFloorSeconds: number; maxIntervalCeilingSeconds: number; @@ -871,6 +927,7 @@ export class InteractionClient { }); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async subscribeMultipleAttributesAndEvents(options: { attributes?: { endpointId?: EndpointNumber; clusterId?: ClusterId; attributeId?: AttributeId }[]; events?: { endpointId?: EndpointNumber; clusterId?: ClusterId; eventId?: EventId; isUrgent?: boolean }[]; @@ -1097,12 +1154,14 @@ export class InteractionClient { throw new MatterFlowError("Received invoke response with no result nor response."); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async invoke(options: InvokeOptions): Promise> { return this.#invoke({ ...options, suppressResponse: false }); } // TODO Add to ClusterClient when needed/when Group communication is implemented // TODO Additionally support it without endpoint + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async invokeWithSuppressedResponse(options: { endpointId?: EndpointNumber; clusterId: ClusterId; @@ -1115,6 +1174,7 @@ export class InteractionClient { return this.#invoke({ ...options, suppressResponse: true }) as Promise; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get channelType() { return this.#exchanges.channelType; } diff --git a/packages/matter.js/src/device/Aggregator.ts b/packages/matter.js/src/device/Aggregator.ts index 3fe9eb04ed..26e903d7ff 100644 --- a/packages/matter.js/src/device/Aggregator.ts +++ b/packages/matter.js/src/device/Aggregator.ts @@ -13,6 +13,8 @@ import { Endpoint, EndpointOptions } from "./Endpoint.js"; /** * An Aggregator is a special endpoint that exposes multiple devices as a "bridge" into the matter ecosystem. * Devices added must already have the BridgedDeviceBasicInformationCluster configured. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class Aggregator extends Endpoint { /** @@ -22,6 +24,7 @@ export class Aggregator extends Endpoint { * @param endpoint Underlying ClientEndpoint instance * @param devices Array of devices to add * @param options Optional Endpoint options + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor(endpoint: NodeEndpoint, devices: Device[] = [], options: EndpointOptions = {}) { // Aggregator is a Composed device with an Aggregator device type @@ -33,11 +36,13 @@ export class Aggregator extends Endpoint { * Returns all bridged devices added to the Aggregator * * @returns Array of bridged devices + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getBridgedDevices() { return this.getChildEndpoints(); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ removeBridgedDevice(device: Device | ComposedDevice) { this.removeChildEndpoint(device); } diff --git a/packages/matter.js/src/device/ComposedDevice.ts b/packages/matter.js/src/device/ComposedDevice.ts index 29dd3d4921..ee1e43a7f4 100644 --- a/packages/matter.js/src/device/ComposedDevice.ts +++ b/packages/matter.js/src/device/ComposedDevice.ts @@ -11,6 +11,8 @@ import { Endpoint, EndpointOptions } from "./Endpoint.js"; /** * A ComposedDevice is a special endpoint that allows to combine multiple sub devices and expose this as one device * (e.g. a fan and a light). + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class ComposedDevice extends Endpoint { /** @@ -20,6 +22,7 @@ export class ComposedDevice extends Endpoint { * @param definition DeviceTypeDefinitions of the composed device * @param devices Array with devices that should be combined into one device that are directly added. * @param options Optional Endpoint options + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor( endpoint: NodeEndpoint, @@ -34,6 +37,7 @@ export class ComposedDevice extends Endpoint { /** * Add a sub-device to the composed device. * @param device Device instance to add + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ addDevice(device: Device) { this.addChildEndpoint(device); @@ -43,6 +47,7 @@ export class ComposedDevice extends Endpoint { * Get all sub-devices of the composed device. * * @returns Array with all sub-devices + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getDevices() { return this.getChildEndpoints(); @@ -50,6 +55,8 @@ export class ComposedDevice extends Endpoint { /** * Verify that the required clusters exists on the device. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ override verifyRequiredClusters() { // TODO find out what to really check here and how ... most likely we need to verify that the added sub devices diff --git a/packages/matter.js/src/device/Device.ts b/packages/matter.js/src/device/Device.ts index cb2ef7af92..3acb9772da 100644 --- a/packages/matter.js/src/device/Device.ts +++ b/packages/matter.js/src/device/Device.ts @@ -15,6 +15,8 @@ import { Endpoint, EndpointOptions } from "./Endpoint.js"; /** * Temporary used device class for paired devices until we added a layer to choose the right specialized device class * based on the device classes and features of the paired device + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class PairedDevice extends Endpoint { /** @@ -24,6 +26,7 @@ export class PairedDevice extends Endpoint { * @param definition DeviceTypeDefinitions of the paired device as reported by the device * @param clusters Clusters of the paired device as reported by the device * @param endpointId Endpoint ID of the paired device as reported by the device + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor( endpoint: NodeEndpoint, @@ -40,10 +43,14 @@ export class PairedDevice extends Endpoint { /** * Root endpoint of a device. This is used internally and not needed to be instanced by the user. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class RootEndpoint extends Endpoint { /** * Create a new RootEndpoint instance. This is automatically instanced by the CommissioningServer class. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor(endpoint: NodeEndpoint) { super(endpoint, [getDeviceTypeDefinitionFromModelByCode(RootNodeDt.id)!], { endpointId: EndpointNumber(0) }); @@ -53,6 +60,7 @@ export class RootEndpoint extends Endpoint { * Add a cluster client to the root endpoint. This is mainly used internally and not needed to be called by the user. * * @param cluster ClusterClient object to add + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ addRootClusterClient(cluster: ClusterClientObj) { this.addClusterClient(cluster); @@ -62,6 +70,7 @@ export class RootEndpoint extends Endpoint { * Get a cluster client from the root endpoint. This is mainly used internally and not needed to be called by the user. * * @param cluster ClusterClient to get or undefined if not existing + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getRootClusterClient(cluster: N): ClusterClientObj | undefined; getRootClusterClient(cluster: ClusterType.Concrete): ClusterClientObj | undefined { @@ -75,6 +84,8 @@ export class RootEndpoint extends Endpoint { /** * Base class for all devices. This class should be extended by all devices. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class Device extends Endpoint { protected commandHandler = new NamedHandler(); @@ -85,6 +96,7 @@ export class Device extends Endpoint { * @param endpoint Underlying ClientEndpoint instance * @param definition DeviceTypeDefinitions of the device * @param options Optional endpoint options + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor(endpoint: NodeEndpoint, definition: DeviceTypeDefinition, options: EndpointOptions = {}) { if (definition.deviceClass === DeviceClasses.Node) { @@ -99,6 +111,7 @@ export class Device extends Endpoint { * * @param command Command name to add a handler for * @param handler Handler function to be executed when the command is received + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ addCommandHandler(command: never, handler: HandlerFunction) { this.commandHandler.addHandler(command, handler); @@ -110,6 +123,7 @@ export class Device extends Endpoint { * * @param command Command name to remove the handler from * @param handler Handler function to be removed + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ removeCommandHandler(command: never, handler: HandlerFunction) { this.commandHandler.removeHandler(command, handler); diff --git a/packages/matter.js/src/device/DeviceInformation.ts b/packages/matter.js/src/device/DeviceInformation.ts index 17da17d22b..f754b7d49c 100644 --- a/packages/matter.js/src/device/DeviceInformation.ts +++ b/packages/matter.js/src/device/DeviceInformation.ts @@ -9,30 +9,37 @@ import { ClientNode, ClientNodePhysicalProperties } from "@matter/node"; import { BasicInformationClient } from "@matter/node/behaviors/basic-information"; import { PhysicalDeviceProperties } from "@matter/protocol"; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export type DeviceInformationData = { basicInformation?: Record; deviceMeta?: PhysicalDeviceProperties; }; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class DeviceInformation { readonly #node: ClientNode; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor(node: ClientNode) { this.#node = node; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get meta() { return ClientNodePhysicalProperties(this.#node); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get basicInformation() { return this.#node.maybeStateOf(BasicInformationClient); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get valid() { return this.basicInformation !== undefined || this.meta !== undefined; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get details(): DeviceInformationData { return { basicInformation: this.basicInformation, diff --git a/packages/matter.js/src/device/DeviceTypes.ts b/packages/matter.js/src/device/DeviceTypes.ts index 234e426074..c995f41bcd 100644 --- a/packages/matter.js/src/device/DeviceTypes.ts +++ b/packages/matter.js/src/device/DeviceTypes.ts @@ -10,6 +10,8 @@ import { ClusterId, DeviceTypeId } from "@matter/types"; /** * General device classification categories. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export enum DeviceClasses { /** Node device type. */ @@ -66,6 +68,7 @@ export enum DeviceClasses { BridgedPowerSourceInfo = "BridgedPowerSourceInfo", } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface DeviceTypeDefinition { name: string; code: DeviceTypeId; @@ -79,6 +82,7 @@ export interface DeviceTypeDefinition { unknown: boolean; } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export const DeviceTypeDefinition = ({ name, code, @@ -120,6 +124,7 @@ export const DeviceTypeDefinition = ({ */ export const DeviceTypes: { [key: string]: DeviceTypeDefinition } = {}; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export const UnknownDeviceType = (code: number, revision: number) => DeviceTypeDefinition({ code, @@ -137,6 +142,7 @@ export function getDeviceTypeDefinitionByCode(code: number): DeviceTypeDefinitio /** Cache of all device types dynamically generated from model. */ const DynamicDeviceType: { [key: string]: DeviceTypeDefinition } = {}; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export function getDeviceTypeDefinitionFromModelByCode(code: number): DeviceTypeDefinition | undefined { if (DynamicDeviceType[code] !== undefined) { return DynamicDeviceType[code]; diff --git a/packages/matter.js/src/device/Endpoint.ts b/packages/matter.js/src/device/Endpoint.ts index 2853019ef9..b2ffa46d65 100644 --- a/packages/matter.js/src/device/Endpoint.ts +++ b/packages/matter.js/src/device/Endpoint.ts @@ -27,11 +27,13 @@ import { Val } from "@matter/protocol"; import { ClusterId, ClusterType, DeviceTypeId, EndpointNumber, getClusterNameById } from "@matter/types"; import { DeviceTypeDefinition } from "./DeviceTypes.js"; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface EndpointOptions { endpointId?: EndpointNumber; uniqueStorageKey?: string; } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class Endpoint { private readonly clusterClients = new Map(); private readonly childEndpoints = new Map(); @@ -49,6 +51,7 @@ export class Endpoint { * @param endpoint The ClientEndpoint this Endpoint represents * @param deviceTypes One or multiple DeviceTypeDefinitions of the endpoint * @param options Options for the endpoint + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor( endpoint: ClientEndpoint, @@ -69,6 +72,8 @@ export class Endpoint { /** * Access to cached cluster state values using endpoint.state.clusterNameOrId.attributeNameOrId * Returns immutable cached attribute values from cluster clients + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get state() { return this.#endpoint.state; @@ -77,6 +82,8 @@ export class Endpoint { /** * Access to cluster commands using endpoint.commands.clusterNameOrId.commandName * Returns async functions that can be called to invoke commands on cluster clients + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get commands() { return this.#endpoint.commands; @@ -88,6 +95,8 @@ export class Endpoint { * Be aware that using a string type does not provide type checking and does not enforce the correctness of the used * Behavior type including all enabled features. Because of this the returned state is typed as a plain string * indexed record (Val.Struct). Please ensure to have proper checks in place when using this method with string type. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ stateOf(type: string): Immutable; @@ -96,6 +105,8 @@ export class Endpoint { * * This is the recommended way to access state for a specific behavior because it provides proper type checking * and enforces the correctness of the used Behavior type including all enabled features. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ stateOf(type: T): Immutable>; @@ -105,11 +116,15 @@ export class Endpoint { /** * Version of {@link stateOf} that returns undefined instead of throwing if the requested behavior is unsupported. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ maybeStateOf(type: string): Immutable | undefined; /** * Version of {@link stateOf} that returns undefined instead of throwing if the requested behavior is unsupported. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ maybeStateOf(type: T): Immutable> | undefined; @@ -127,6 +142,7 @@ export class Endpoint { * * @param type the {@link Behavior} to patch * @param values the values to change + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ setStateOf(type: T, values: Behavior.PatchStateOf): Promise; @@ -141,6 +157,7 @@ export class Endpoint { * * @param type the {@link Behavior} to patch * @param values the values to change + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ setStateOf(type: string, values: Val.Struct): Promise; @@ -148,37 +165,59 @@ export class Endpoint { return this.#endpoint.setStateOf(type as Behavior.Type, values); } - /** Cluster commands for a behavior id (untyped: each command is `Commands.Command`). */ + /** + * Cluster commands for a behavior id (untyped: each command is `Commands.Command`). + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ commandsOf(type: string): Record; - /** Typed variant of {@link commandsOf}; preserves the behavior's command interface. */ + /** + * Typed variant of {@link commandsOf}; preserves the behavior's command interface. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ commandsOf(type: T): Commands.OfBehavior; commandsOf(type: Behavior.Type | string): unknown { return this.#endpoint.commandsOf(type as Behavior.Type); } - /** Activated cluster features for a behavior id (untyped). */ + /** + * Activated cluster features for a behavior id (untyped). + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ featuresOf(type: string): Immutable>; - /** Typed variant of {@link featuresOf}; preserves the cluster's per-feature flag type. */ + /** + * Typed variant of {@link featuresOf}; preserves the cluster's per-feature flag type. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ featuresOf(type: T): T["features"]; featuresOf(type: ClusterBehavior.Type | string) { return this.#endpoint.featuresOf(type as ClusterBehavior.Type); } - /** {@link featuresOf} variant returning undefined for unknown or non-cluster behaviors. */ + /** + * {@link featuresOf} variant returning undefined for unknown or non-cluster behaviors. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ maybeFeaturesOf(type: string): Immutable> | undefined; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ maybeFeaturesOf(type: T): T["features"] | undefined; maybeFeaturesOf(type: ClusterBehavior.Type | string) { return this.#endpoint.maybeFeaturesOf(type as ClusterBehavior.Type); } - /** Global cluster attribute state (clusterRevision, featureMap, attributeList, ...) for a behavior id. */ + /** + * Global cluster attribute state (clusterRevision, featureMap, attributeList, ...) for a behavior id. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ globalsOf(type: string): Immutable; - /** Typed variant of {@link globalsOf}; narrows `featureMap` to the cluster's per-feature flag type. */ + /** + * Typed variant of {@link globalsOf}; narrows `featureMap` to the cluster's per-feature flag type. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ globalsOf( type: T, ): Immutable & { featureMap: T["features"] }>; @@ -187,8 +226,12 @@ export class Endpoint { return this.#endpoint.globalsOf(type as ClusterBehavior.Type); } - /** {@link globalsOf} variant returning undefined for unknown or non-cluster behaviors. */ + /** + * {@link globalsOf} variant returning undefined for unknown or non-cluster behaviors. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ maybeGlobalsOf(type: string): Immutable | undefined; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ maybeGlobalsOf( type: T, ): Immutable & { featureMap: T["features"] }> | undefined; @@ -200,8 +243,10 @@ export class Endpoint { * Read selected behavior state via the underlying client endpoint. * * @see {@link ClientEndpoint.get} + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get(): Promise; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get(selector: object | undefined, options?: ClientEndpoint.GetOptions): Promise; get(selector?: object, options?: ClientEndpoint.GetOptions): Promise { return this.#endpoint.get(selector as never, options); @@ -211,17 +256,20 @@ export class Endpoint { * Read state for a single behavior via the underlying client endpoint. * * @see {@link ClientEndpoint.getStateOf} + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getStateOf( type: B, selector?: true, options?: ClientEndpoint.GetOptions, ): Promise>; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getStateOf>( type: B, selector: readonly K[], options?: ClientEndpoint.GetOptions, ): Promise<{ readonly [P in K]?: Behavior.StateOf[P] }>; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getStateOf(type: string, selector?: readonly string[], options?: ClientEndpoint.GetOptions): Promise; getStateOf( type: Behavior.Type | string, @@ -237,6 +285,8 @@ export class Endpoint { * Be aware that using a string type does not provide type checking and does not enforce the correctness of the used * Behavior type including all enabled features. Because of this each event is typed as Observable | undefined. * Please ensure to have proper checks in place when using this method with string type. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ eventsOf(type: string): Immutable>; @@ -245,6 +295,8 @@ export class Endpoint { * * This is the recommended way to access events for a specific behavior because it provides proper type checking * and enforces the correctness of the used Behavior type including all enabled features. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ eventsOf(type: T): Behavior.EventsOf; @@ -252,23 +304,30 @@ export class Endpoint { return this.#endpoint.eventsOf(type as any); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get behaviors() { return this.#endpoint.behaviors; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get endpoint() { return this.#endpoint; } - /** Get all child endpoints aka parts */ + /** + * Get all child endpoints aka parts + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get parts() { return this.childEndpoints; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get deviceType(): DeviceTypeId { return this.deviceTypes[0].code; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ setStructureChangedCallback(callback: () => void) { this.structureChangedCallback = callback; for (const endpoint of this.childEndpoints.values()) { @@ -276,6 +335,7 @@ export class Endpoint { } } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ removeFromStructure() { this.structureChangedCallback = () => { /** noop **/ @@ -285,10 +345,12 @@ export class Endpoint { } } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ close() { // noop — server cleanup removed } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getNumber() { if (this.number === undefined) { throw new InternalError("Endpoint has not been assigned yet"); @@ -296,27 +358,33 @@ export class Endpoint { return this.number; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ addClusterClient(cluster: ClusterClientObj) { this.clusterClients.set(cluster.id, cluster); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getClusterClient(cluster: N): ClusterClientObj | undefined; getClusterClient(cluster: ClusterType.Concrete): ClusterClientObj | undefined { return this.clusterClients.get(cluster.id) as ClusterClientObj; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getClusterClientById(clusterId: ClusterId): ClusterClientObj | undefined { return this.clusterClients.get(clusterId); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ hasClusterClient(cluster: ClusterType.Concrete): boolean { return this.clusterClients.has(cluster.id); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getDeviceTypes(): AtLeastOne { return this.deviceTypes; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ setDeviceTypes(deviceTypes: AtLeastOne): void { // Remove duplicates, for now we ignore that there could be different revisions const deviceTypeList = new Map(); @@ -325,6 +393,7 @@ export class Endpoint { this.name = deviceTypes[0].name; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ addChildEndpoint(endpoint: Endpoint): void { if (!(endpoint instanceof Endpoint)) { throw new InternalError("Only supported EndpointInterface implementation is Endpoint"); @@ -340,14 +409,17 @@ export class Endpoint { this.structureChangedCallback(); // Inform parent about structure change } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getChildEndpoint(id: EndpointNumber): Endpoint | undefined { return this.childEndpoints.get(id); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getChildEndpoints(): Endpoint[] { return Array.from(this.childEndpoints.values()); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ removeChildEndpoint(endpoint: Endpoint): void { const id = endpoint.getNumber(); const knownEndpoint = this.childEndpoints.get(id); @@ -359,6 +431,7 @@ export class Endpoint { this.structureChangedCallback(); // Inform parent about structure change } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ determineUniqueID(): string | undefined { // if the options in constructor contained a custom uniqueStorageKey, use this if (this.uniqueStorageKey !== undefined) { @@ -366,6 +439,7 @@ export class Endpoint { } } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ public verifyRequiredClusters(): void { this.deviceTypes.forEach(deviceType => { if (this.clusterClients.size > 0) { @@ -387,12 +461,15 @@ export class Endpoint { }); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getAllClusterClients(): ClusterClientObj[] { return Array.from(this.clusterClients.values()); } /** * Hierarchical diagnostics of endpoint and children. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get [Diagnostic.value](): unknown[] { return [ diff --git a/packages/matter.js/src/device/PairedNode.ts b/packages/matter.js/src/device/PairedNode.ts index 2e88fbc1c1..41af0ad73d 100644 --- a/packages/matter.js/src/device/PairedNode.ts +++ b/packages/matter.js/src/device/PairedNode.ts @@ -82,6 +82,7 @@ const logger = Logger.get("PairedNode"); /** Delay after receiving a changed partList from a device to update the device structure */ const STRUCTURE_UPDATE_TIMEOUT = Seconds(5); +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export enum NodeStates { /** * Node seems active nd last communications were successful and subscription updates were received and all data is @@ -108,7 +109,7 @@ export enum NodeStates { WaitingForDeviceDiscovery = 3, } -/** @deprecated */ +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export enum NodeStateInformation { /** * Node seems active nd last communications were successful and subscription updates were received and all data is @@ -146,6 +147,7 @@ export enum NodeStateInformation { Decommissioned = 5, } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export type CommissioningControllerNodeOptions = { /** * Unless set to false the node will be automatically connected when initialized. When set to false use @@ -203,6 +205,7 @@ export type CommissioningControllerNodeOptions = { readonly caseAuthenticatedTags?: CaseAuthenticatedTag[]; }; +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export class NodeNotConnectedError extends MatterError {} /** @@ -222,6 +225,8 @@ function areNumberListsSame(list1: Immutable, list2: Immutable(); + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ readonly events: PairedNode.Events = { initialized: AsyncObservable<[details: DeviceInformationData]>(), initializedFromRemote: AsyncObservable<[details: DeviceInformationData]>(), @@ -277,6 +283,7 @@ export class PairedNode { connectionAlive: Observable<[void]>(), }; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ static async create( nodeId: NodeId, commissioningController: CommissioningController, @@ -301,6 +308,7 @@ export class PairedNode { return node; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ constructor( readonly nodeId: NodeId, commissioningController: CommissioningController, @@ -402,53 +410,78 @@ export class PairedNode { }); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get construction() { return this.#construction; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get isConnected() { return this.#connectionState === NodeStates.Connected; } - /** Returns the Node connection state. */ + /** + * Returns the Node connection state. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get connectionState() { return this.#connectionState; } - /** Returns the BasicInformation cluster metadata collected from the device. */ + /** + * Returns the BasicInformation cluster metadata collected from the device. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get basicInformation() { return this.#nodeDetails.basicInformation; } - /** Returns the general capability metadata collected from the device. */ + /** + * Returns the general capability metadata collected from the device. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get deviceInformation() { return this.#nodeDetails.meta; } - /** Is the Node fully initialized with formerly stored subscription data? False when the node was never connected so far. */ + /** + * Is the Node fully initialized with formerly stored subscription data? False when the node was never connected so far. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get localInitializationDone() { return this.#localInitializationDone; } - /** Is the Node fully initialized with remote subscription or read data? */ + /** + * Is the Node fully initialized with remote subscription or read data? + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get remoteInitializationDone() { return this.#remoteInitializationDone; } - /** Is the Node initialized - locally or remotely? */ + /** + * Is the Node initialized - locally or remotely? + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get initialized() { return this.#remoteInitializationDone || this.#localInitializationDone; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get id() { return this.#clientNode.id; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get node() { return this.#clientNode; } - /** If a subscription is established, then this is the interval in seconds, otherwise undefined */ + /** + * If a subscription is established, then this is the interval in seconds, otherwise undefined + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get currentSubscriptionIntervalSeconds() { const sub = this.#clientNode.behaviors.internalsOf(NetworkClient).activeSubscription; return sub?.maxInterval !== undefined ? sub.maxInterval : undefined; @@ -515,6 +548,8 @@ export class PairedNode { * The connection happens in the background. Please monitor the state events of the node to see if the * connection was successful. * The provided connection options will be set and used internally if the node reconnects successfully. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ connect(connectOptions?: CommissioningControllerNodeOptions) { if (this.#decommissioned) { @@ -565,6 +600,8 @@ export class PairedNode { * Trigger a reconnection to the device. This method is non-blocking and will return immediately. * The reconnection happens in the background. Please monitor the state events of the node to see if the * reconnection was successful. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ triggerReconnect() { this.reconnect().catch(error => logger.error(this.#peerAddress, `Failed to reconnect to node`, error)); @@ -572,6 +609,8 @@ export class PairedNode { /** * Force a reconnection by tearing down and re-establishing the sustained subscription. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async reconnect(connectOptions?: CommissioningControllerNodeOptions) { if (this.#decommissioned) { @@ -828,12 +867,17 @@ export class PairedNode { * Request the current InteractionClient for custom special interactions with the device. Usually the * ClusterClients of the Devices of the node should be used instead. An own InteractionClient is only needed * when you want to read or write multiple attributes or events in a single request or send batch invokes. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getInteractionClient() { return this.#interactionClient; } - /** Method to log the structure of this node with all endpoints and clusters. */ + /** + * Method to log the structure of this node with all endpoints and clusters. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ logStructure() { logger.info(this.#clientNode); } @@ -1276,7 +1320,10 @@ export class PairedNode { } } - /** Returns all parts (endpoints) known for the Root Endpoint of this node. */ + /** + * Returns all parts (endpoints) known for the Root Endpoint of this node. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ get parts() { return this.getRootEndpoint()?.parts ?? new Map(); } @@ -1289,22 +1336,34 @@ export class PairedNode { return this.#endpoints!; } - /** Returns the functional devices/endpoints (the "children" of the Root Endpoint) known for this node. */ + /** + * Returns the functional devices/endpoints (the "children" of the Root Endpoint) known for this node. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ getDevices(): Endpoint[] { return this.#ensureLegacyEndpointStructure().get(EndpointNumber(0))?.getChildEndpoints() ?? []; } - /** Returns the device/endpoint with the given endpoint ID. */ + /** + * Returns the device/endpoint with the given endpoint ID. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ getDeviceById(endpointId: number) { return this.#ensureLegacyEndpointStructure().get(EndpointNumber(endpointId)); } - /** Returns the Root Endpoint of the device. */ + /** + * Returns the Root Endpoint of the device. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ getRootEndpoint() { return this.getDeviceById(0); } - /** De-Commission (unpair) the device from this controller by removing the fabric from the device. */ + /** + * De-Commission (unpair) the device from this controller by removing the fabric from the device. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ async decommission() { if ( this.#connectionState === NodeStates.Reconnecting || @@ -1339,6 +1398,8 @@ export class PairedNode { * Opens a Basic Commissioning Window (uses the original Passcode printed on the device) with the device. * This is an optional method, so it might not be supported by all devices and could be rejected with an error in * this case! Better use openEnhancedCommissioningWindow() instead. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ async openBasicCommissioningWindow(commissioningTimeout = 900 /* 15 minutes */) { const adminCommissioningCluster = this.getRootClusterClient(AdministratorCommissioning); @@ -1366,7 +1427,10 @@ export class PairedNode { await adminCommissioningCluster.commands.openBasicCommissioningWindow({ commissioningTimeout }); } - /** Opens an Enhanced Commissioning Window (uses a generated random Passcode) with the device. */ + /** + * Opens an Enhanced Commissioning Window (uses a generated random Passcode) with the device. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ async openEnhancedCommissioningWindow(commissioningTimeout = 900 /* 15 minutes */) { const adminCommissioningCluster = this.getRootClusterClient(AdministratorCommissioning); if (adminCommissioningCluster === undefined) { @@ -1434,7 +1498,10 @@ export class PairedNode { }; } - /** Closes the current session, ends the subscription and disconnects the device. The node can be reconnected via connect(). */ + /** + * Closes the current session, ends the subscription and disconnects the device. The node can be reconnected via connect(). + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ async disconnect() { // Unlike close() this keeps the instance (observers, construction) intact so connect() can reconnect it; the // node is disabled via disconnectNode() which ends the subscription. @@ -1443,7 +1510,10 @@ export class PairedNode { await this.#commissioningController.disconnectNode(this.nodeId); } - /** Closes the subscription and ends all timers used by this PairedNode instance. */ + /** + * Closes the subscription and ends all timers used by this PairedNode instance. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ close(sendDecommissionedStatus = false) { this.#closing = true; this.#observers.close(); @@ -1462,6 +1532,7 @@ export class PairedNode { * Get a cluster client from the root endpoint. This is mainly used internally and not needed to be called by the user. * * @param cluster ClusterClient to get or undefined if not existing + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getRootClusterClient(cluster: N): ClusterClientObj | undefined; getRootClusterClient(cluster: ClusterType.Concrete): ClusterClientObj | undefined { @@ -1473,6 +1544,7 @@ export class PairedNode { * * @param endpointId EndpointNumber to get the cluster from * @param cluster ClusterClient to get or undefined if not existing + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getClusterClientForDevice( endpointId: EndpointNumber, @@ -1482,6 +1554,7 @@ export class PairedNode { return this.getDeviceById(endpointId)?.getClusterClient(cluster); } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get [Diagnostic.value](): unknown { const root = this.getRootEndpoint(); @@ -1511,6 +1584,8 @@ export class PairedNode { /** * Access to cached cluster state values of the root endpoint using node.state.clusterNameOrId.attributeNameOrId * Returns immutable cached attribute values from cluster clients + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get state() { return this.#clientNode.state; @@ -1519,6 +1594,8 @@ export class PairedNode { /** * Access to cluster commands of the root endpoint using node.commands.clusterNameOrId.commandName * Returns async functions that can be called to invoke commands on cluster clients + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get commands() { return this.#clientNode.commands; @@ -1530,6 +1607,8 @@ export class PairedNode { * Be aware that using a string type does not provide type checking and does not enforce the correctness of the used * Behavior type including all enabled features. Because of this the returned state is typed as a plain string * indexed record (Val.Struct). Please ensure to have proper checks in place when using this method with string type. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ stateOf(type: string): Immutable; @@ -1538,6 +1617,8 @@ export class PairedNode { * * This is the recommended way to access state for a specific behavior because it provides proper type checking * and enforces the correctness of the used Behavior type including all enabled features. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ stateOf(type: T): Immutable>; @@ -1547,11 +1628,15 @@ export class PairedNode { /** * Version of {@link stateOf} that returns undefined instead of throwing if the requested behavior is unsupported. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ maybeStateOf(type: string): Immutable | undefined; /** * Version of {@link stateOf} that returns undefined instead of throwing if the requested behavior is unsupported. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ maybeStateOf(type: T): Immutable> | undefined; @@ -1559,37 +1644,59 @@ export class PairedNode { return this.#clientNode.maybeStateOf(type as any); } - /** Cluster commands for a behavior id on the root endpoint (untyped). */ + /** + * Cluster commands for a behavior id on the root endpoint (untyped). + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ commandsOf(type: string): Record; - /** Typed variant of {@link commandsOf}. */ + /** + * Typed variant of {@link commandsOf}. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ commandsOf(type: T): Commands.OfBehavior; commandsOf(type: Behavior.Type | string): unknown { return this.#clientNode.commandsOf(type as Behavior.Type); } - /** Activated cluster features for a behavior id on the root endpoint (untyped). */ + /** + * Activated cluster features for a behavior id on the root endpoint (untyped). + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ featuresOf(type: string): Immutable>; - /** Typed variant of {@link featuresOf}; preserves the cluster's per-feature flag type. */ + /** + * Typed variant of {@link featuresOf}; preserves the cluster's per-feature flag type. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ featuresOf(type: T): T["features"]; featuresOf(type: ClusterBehavior.Type | string) { return this.#clientNode.featuresOf(type as ClusterBehavior.Type); } - /** {@link featuresOf} variant returning undefined for unknown or non-cluster behaviors. */ + /** + * {@link featuresOf} variant returning undefined for unknown or non-cluster behaviors. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ maybeFeaturesOf(type: string): Immutable> | undefined; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ maybeFeaturesOf(type: T): T["features"] | undefined; maybeFeaturesOf(type: ClusterBehavior.Type | string) { return this.#clientNode.maybeFeaturesOf(type as ClusterBehavior.Type); } - /** Global cluster attribute state for a behavior id on the root endpoint (untyped). */ + /** + * Global cluster attribute state for a behavior id on the root endpoint (untyped). + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ globalsOf(type: string): Immutable; - /** Typed variant of {@link globalsOf}; narrows `featureMap` to the cluster's per-feature flag type. */ + /** + * Typed variant of {@link globalsOf}; narrows `featureMap` to the cluster's per-feature flag type. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ globalsOf( type: T, ): Immutable & { featureMap: T["features"] }>; @@ -1598,8 +1705,12 @@ export class PairedNode { return this.#clientNode.globalsOf(type as ClusterBehavior.Type); } - /** {@link globalsOf} variant returning undefined for unknown or non-cluster behaviors. */ + /** + * {@link globalsOf} variant returning undefined for unknown or non-cluster behaviors. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ maybeGlobalsOf(type: string): Immutable | undefined; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ maybeGlobalsOf( type: T, ): Immutable & { featureMap: T["features"] }> | undefined; @@ -1611,8 +1722,10 @@ export class PairedNode { * Read selected behavior state on the root endpoint via the underlying client node. * * @see {@link ClientEndpoint.get} + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get(): Promise; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ get(selector: object | undefined, options?: ClientEndpoint.GetOptions): Promise; get(selector?: object, options?: ClientEndpoint.GetOptions): Promise { return this.#clientNode.get(selector as never, options); @@ -1622,17 +1735,20 @@ export class PairedNode { * Read state for a single behavior on the root endpoint via the underlying client node. * * @see {@link ClientEndpoint.getStateOf} + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getStateOf( type: B, selector?: true, options?: ClientEndpoint.GetOptions, ): Promise>; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getStateOf>( type: B, selector: readonly K[], options?: ClientEndpoint.GetOptions, ): Promise<{ readonly [P in K]?: Behavior.StateOf[P] }>; + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ getStateOf(type: string, selector?: readonly string[], options?: ClientEndpoint.GetOptions): Promise; getStateOf( type: Behavior.Type | string, @@ -1652,6 +1768,8 @@ export class PairedNode { * Note: this exposes per-behavior events of the root endpoint. The {@link events} field on this class is the * lifecycle event bus of the {@link PairedNode} itself ({@link PairedNode.Events}) and unrelated to behavior * events. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ eventsOf(type: string): Immutable>; @@ -1660,6 +1778,8 @@ export class PairedNode { * * This is the recommended way to access events for a specific behavior because it provides proper type checking * and enforces the correctness of the used Behavior type including all enabled features. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ eventsOf(type: T): Behavior.EventsOf; @@ -1668,57 +1788,92 @@ export class PairedNode { } } +/** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export namespace PairedNode { + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface NodeStructureEvents { - /** Emitted when endpoints are added. */ + /** + * Emitted when endpoints are added. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ nodeEndpointAdded: Observable<[EndpointNumber]>; - /** Emitted when endpoints are removed. */ + /** + * Emitted when endpoints are removed. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ nodeEndpointRemoved: Observable<[EndpointNumber]>; - /** Emitted when endpoints are updated (e.g. device type changed, structure changed). */ + /** + * Emitted when endpoints are updated (e.g. device type changed, structure changed). + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ nodeEndpointChanged: Observable<[EndpointNumber]>; } + /** @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ export interface Events extends NodeStructureEvents { /** * Emitted when the node is initialized from local data. These data usually are stale, but you can still already * use the node to interact with the device. If no local data are available this event will be emitted together * with the initializedFromRemote event. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ initialized: AsyncObservable<[details: DeviceInformationData]>; /** * Emitted when the node is fully initialized from remote and all attributes and events are subscribed. * This event can also be awaited if code needs to be blocked until the node is fully initialized. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ initializedFromRemote: AsyncObservable<[details: DeviceInformationData]>; /** * Emitted when the device information changes. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ deviceInformationChanged: AsyncObservable<[details: DeviceInformationData]>; - /** Emitted when the state of the node changes. */ + /** + * Emitted when the state of the node changes. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ stateChanged: Observable<[nodeState: NodeStates]>; - /** Emitted when an attribute value changes. */ + /** + * Emitted when an attribute value changes. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ attributeChanged: Observable<[data: DecodedAttributeReportValue]>; - /** Emitted when an event is triggered. */ + /** + * Emitted when an event is triggered. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ eventTriggered: Observable<[DecodedEventReportValue]>; /** * Emitted when all node structure changes were applied (Endpoints got added or also removed). * You can alternatively use the nodeEndpointAdded, nodeEndpointRemoved, and nodeEndpointChanged events to react on specific changes. * This event is emitted after all nodeEndpointAdded, nodeEndpointRemoved, and nodeEndpointChanged events are emitted. + * + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. */ structureChanged: Observable<[void]>; - /** Emitted when the node is decommissioned. */ + /** + * Emitted when the node is decommissioned. + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ decommissioned: Observable<[void]>; - /** Emitted when a subscription alive trigger is received (max interval trigger or any data update) */ + /** + * Emitted when a subscription alive trigger is received (max interval trigger or any data update) + * @deprecated Legacy API, removed in 0.19. Migrate to @matter/node — see docs/MIGRATION_CONTROLLER_018.md. + */ connectionAlive: Observable<[void]>; } } diff --git a/packages/node/src/behavior/system/commissioning/CommissioningClient.ts b/packages/node/src/behavior/system/commissioning/CommissioningClient.ts index 8b9f30cc02..8cbd1c977a 100644 --- a/packages/node/src/behavior/system/commissioning/CommissioningClient.ts +++ b/packages/node/src/behavior/system/commissioning/CommissioningClient.ts @@ -7,6 +7,7 @@ import { Behavior } from "#behavior/Behavior.js"; import { Events as BaseEvents } from "#behavior/Events.js"; import { SoftwareUpdateManager } from "#behavior/system/software-update/SoftwareUpdateManager.js"; +import { AdministratorCommissioningClient } from "#behaviors/administrator-commissioning"; import { BasicInformationClient } from "#behaviors/basic-information"; import { OperationalCredentialsClient } from "#behaviors/operational-credentials"; import { OtaSoftwareUpdateProviderServer } from "#behaviors/ota-software-update-provider"; @@ -16,6 +17,8 @@ import type { ServerNode } from "#node/ServerNode.js"; import { causedBy, ClassExtends, + Crypto, + CRYPTO_PBKDF_ITERATIONS_MIN, Diagnostic, Duration, ImplementationError, @@ -61,6 +64,7 @@ import { FabricRemovedError, LocatedNodeCommissioningOptions, OperationalAddress, + PaseClient, Peer, PeerAddress, PeerInitiatedCloseError, @@ -74,14 +78,20 @@ import { } from "@matter/protocol"; import { CaseAuthenticatedTag, + CommissioningFlowType, DeviceTypeId, DiscoveryCapabilitiesBitmap, + DiscoveryCapabilitiesSchema, FabricIndex, ManualPairingCodeCodec, NodeId, + QrPairingCodeCodec, + Status, + StatusResponseError, TypeFromPartialBitSchema, VendorId, } from "@matter/types"; +import { AdministratorCommissioning } from "@matter/types/clusters/administrator-commissioning"; import { OperationalCredentials } from "@matter/types/clusters/operational-credentials"; import { ControllerBehavior } from "../controller/ControllerBehavior.js"; import { NetworkClient } from "../network/NetworkClient.js"; @@ -403,6 +413,103 @@ export class CommissioningClient extends Behavior { } } + /** + * Open a Basic Commissioning Window (uses the passcode originally printed on the device). + * + * This is an optional feature and not all devices support it; use {@link openEnhancedCommissioningWindow} unless + * you specifically need the basic commissioning method. + */ + async openBasicCommissioningWindow(commissioningTimeout: Duration = Seconds(900)) { + if (!this.endpoint.featuresOf(AdministratorCommissioningClient).basic) { + throw new ImplementationError(`${this.endpoint} does not support the basic commissioning method`); + } + + const adminCommissioning = this.agent.get(AdministratorCommissioningClient); + await this.#revokeStaleCommissioningWindow(adminCommissioning); + + await adminCommissioning.openBasicCommissioningWindow({ + commissioningTimeout: Seconds.of(commissioningTimeout), + }); + } + + /** + * Open an Enhanced Commissioning Window using a freshly generated random passcode. + * + * The peer's BasicInformation must be seeded — its vendor/product IDs are encoded into the QR pairing code — so + * this throws (before opening any window) if it is not. + * + * @returns the manual and QR pairing codes encoding the generated passcode. + */ + async openEnhancedCommissioningWindow( + commissioningTimeout: Duration = Seconds(900), + ): Promise<{ manualPairingCode: string; qrPairingCode: string }> { + const basicInformation = this.endpoint.maybeStateOf(BasicInformationClient); + if (basicInformation === undefined) { + throw new ImplementationError( + `${this.endpoint} must be seeded (BasicInformation) before opening an enhanced commissioning window; its vendor/product IDs are encoded in the QR pairing code`, + ); + } + + const adminCommissioning = this.agent.get(AdministratorCommissioningClient); + await this.#revokeStaleCommissioningWindow(adminCommissioning); + + const crypto = this.env.get(Crypto); + const discriminator = PaseClient.generateRandomDiscriminator(crypto); + const passcode = PaseClient.generateRandomPasscode(crypto); + const salt = crypto.randomBytes(32); + const iterations = CRYPTO_PBKDF_ITERATIONS_MIN; + const pakePasscodeVerifier = await PaseClient.generatePakePasscodeVerifier(crypto, passcode, { + iterations, + salt, + }); + + await adminCommissioning.openCommissioningWindow({ + commissioningTimeout: Seconds.of(commissioningTimeout), + pakePasscodeVerifier, + salt, + iterations, + discriminator, + }); + + const { vendorId, productId } = basicInformation; + + // TODO: If the timeout is shorter than 15 minutes, also encode it in the QR code's TLV data. + const qrPairingCode = QrPairingCodeCodec.encode([ + { + version: 0, + vendorId, + productId, + flowType: CommissioningFlowType.Standard, + discriminator, + passcode, + discoveryCapabilities: DiscoveryCapabilitiesSchema.encode({ onIpNetwork: true }), + }, + ]); + + return { + manualPairingCode: ManualPairingCodeCodec.encode({ + discriminator, + passcode, + flowType: CommissioningFlowType.Standard, + }), + qrPairingCode, + }; + } + + /** Tolerates the device reporting that no window is currently open, so a stale window can be revoked blindly. */ + async #revokeStaleCommissioningWindow(adminCommissioning: AdministratorCommissioningClient) { + try { + await adminCommissioning.revokeCommissioning(); + } catch (error) { + if ( + !StatusResponseError.is(error, Status.Failure) || + StatusResponseError.of(error)?.clusterCode !== AdministratorCommissioning.StatusCode.WindowNotOpen + ) { + throw error; + } + } + } + /** * Override to implement CASE commissioning yourself. * @@ -1005,15 +1112,19 @@ export namespace CommissioningClient { regulatoryCountryCode?: ControllerCommissioningFlowOptions["regulatoryCountryCode"]; /** - * Override the final commissioning step. + * Override the final operational step of commissioning. * - * When provided, matter.js completes commissioning over PASE and then calls this function instead of performing - * the CASE reconnection and "CommissioningComplete" command internally. The function must connect to the device - * operationally and invoke "CommissioningComplete" itself. + * The flow runs PASE, arm-failsafe, CSR and AddNOC as usual, then invokes this hook **instead of** connecting + * to the device operationally and sending "CommissioningComplete" itself, and awaits it. The hook owns the + * finalization and must drive it to completion before resolving: resolve on success, throw on failure. + * `commission()` resolves or rejects with this hook's outcome — a throw rolls the commissioning back. * - * This is used by {@link PaseCommissioner} so that a lightweight commissioner can perform the PASE phase and - * then hand off to a full controller to finish commissioning. - * TODO: Revisit when we decide how to continue with the PaseCommissioner approach at all + * For a delegated/split flow, hand `address.nodeId` and `discoveryData` to the controller that will finish + * (typically a separate, network-side controller sharing this fabric) and **await** its + * `serverNode.peers.completeCommissioning(nodeId, discoveryData)` from within this hook. Do not return before + * that completes: the PASE session is held open across the hook and the device's failsafe stays armed until + * "CommissioningComplete" arrives, so returning early leaves the device mid-commission and it reverts, + * discarding the freshly issued NOC. See docs/MIGRATION_CONTROLLER_018.md for the full recipe. */ finalizeCommissioning?: (address: ProtocolPeerAddress, discoveryData?: DiscoveryData) => Promise; diff --git a/packages/node/src/behavior/system/network/NetworkBehavior.ts b/packages/node/src/behavior/system/network/NetworkBehavior.ts index a78fac51f7..9bf414132c 100644 --- a/packages/node/src/behavior/system/network/NetworkBehavior.ts +++ b/packages/node/src/behavior/system/network/NetworkBehavior.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { MaybePromise, STANDARD_MATTER_PORT } from "@matter/general"; +import { MaybePromise } from "@matter/general"; import { Behavior } from "../../Behavior.js"; import type { NetworkRuntime } from "./NetworkRuntime.js"; @@ -37,7 +37,11 @@ export namespace NetworkBehavior { } export class State { - port = STANDARD_MATTER_PORT; + /** + * The operational port the node binds. When unset the node binds the standard Matter port (5540) if it is + * commissionable, or an ephemeral port if commissioning is disabled (e.g. a controller). + */ + port?: number = undefined; operationalPort = -1; } } diff --git a/packages/node/src/behavior/system/network/NetworkClient.ts b/packages/node/src/behavior/system/network/NetworkClient.ts index ff333f5787..060a10caf1 100644 --- a/packages/node/src/behavior/system/network/NetworkClient.ts +++ b/packages/node/src/behavior/system/network/NetworkClient.ts @@ -5,9 +5,11 @@ */ import { RemoteDescriptor } from "#behavior/system/commissioning/RemoteDescriptor.js"; +import { IcdClient } from "#behavior/system/icd/IcdClient.js"; import { ClientNodeInteraction } from "#node/client/ClientNodeInteraction.js"; import { ClientNodePhysicalProperties } from "#node/client/ClientNodePhysicalProperties.js"; import type { ClientNode } from "#node/ClientNode.js"; +import { NodeConnectionState } from "#node/ClientNodeLifecycle.js"; import { Node } from "#node/Node.js"; import { ClientCacheBuffer } from "#storage/client/ClientCacheBuffer.js"; import { ChannelType, Observable, ServerAddress } from "@matter/general"; @@ -41,6 +43,10 @@ export class NetworkClient extends NetworkBehavior { offline: true, }); this.reactTo(this.events.subscriptionStatusChanged, this.#flushCacheOnSubscribed); + + this.reactTo(this.events.subscriptionStatusChanged, this.#recomputeConnectionState); + this.reactTo(this.events.isDisabled$Changed, this.#recomputeConnectionState); + this.reactTo(this.#node.lifecycle.decommissioned, this.#recomputeConnectionState); } } @@ -79,11 +85,29 @@ export class NetworkClient extends NetworkBehavior { this.state.transportPreference ?? (this.#node.owner?.state as Record | undefined)?.network?.transportPreference; peer.transportPreference = pref === "tcp" ? ChannelType.TCP : undefined; + + if (this.#node.nodeType !== "group") { + // These emit synchronously from the peer's retransmission/session paths with no context, so the + // handlers run in an isolated transaction (offline) and a fault cannot escape into those paths. + this.reactTo(peer.establishmentUnresponsive, this.#markLikelyOffline, { offline: true }); + this.reactTo(peer.sessions.added, this.#onSessionEstablished, { offline: true }); + } } } await this.#syncAutoSubscribe(); + if (this.#node.nodeType !== "group") { + if (this.endpoint.behaviors.has(IcdClient)) { + // Gated on startup-time presence only; ICD support installed later falls back to the slower + // establishment-unresponsive path for offline detection rather than this check-in fast path. + this.reactTo(this.endpoint.eventsOf(IcdClient).checkInMissed, this.#markLikelyOffline, { + offline: true, + }); + } + this.#recomputeConnectionState(); + } + this.internal.runtime!.isReady = true; } @@ -194,6 +218,50 @@ export class NetworkClient extends NetworkBehavior { return activeSubscription instanceof SustainedSubscription ? activeSubscription.active.value : true; } + /** + * Recompute the node's {@link NodeConnectionState} from the current liveness signals and push it into the + * lifecycle. A live subscription is authoritative and clears the likely-offline latch. + */ + #recomputeConnectionState() { + const { lifecycle } = this.#node; + + let state: NodeConnectionState; + if (this.subscriptionActive) { + this.internal.likelyOffline = false; + state = NodeConnectionState.Connected; + } else if (this.state.isDisabled || lifecycle.shouldBeOffline) { + // Disabled, stopped or not yet started. Reset the offline latch so a later re-enable/restart does not + // resume in WaitingForDeviceDiscovery before any fresh session. + this.internal.likelyOffline = false; + state = NodeConnectionState.Disconnected; + } else if (this.internal.likelyOffline) { + state = NodeConnectionState.WaitingForDeviceDiscovery; + } else { + state = NodeConnectionState.Reconnecting; + } + + lifecycle.setConnectionState(state); + } + + /** + * Latch the peer as likely offline (idempotent) and re-evaluate. Driven by an establishment-unresponsive signal + * or a registered ICD missing its check-in. + */ + #markLikelyOffline() { + this.internal.likelyOffline = true; + this.#recomputeConnectionState(); + } + + /** + * A fresh session is proof the peer responded, so drop the likely-offline latch and re-evaluate. This relaxes + * {@link NodeConnectionState.WaitingForDeviceDiscovery} back to {@link NodeConnectionState.Reconnecting} until the + * subscription re-establishes. + */ + #onSessionEstablished() { + this.internal.likelyOffline = false; + this.#recomputeConnectionState(); + } + override async [Symbol.asyncDispose]() { // Clean up any active subscription this.internal.activeSubscription?.close(); @@ -277,6 +345,13 @@ export namespace NetworkClient { * ensures we have a complete snapshot of the node's state when commissioning logic is complete. */ isNewlyCommissioned = false; + + /** + * Latch indicating the peer is likely offline (unresponsive establishment, hard send failure or a missed ICD + * check-in). Set by liveness signals, cleared on a fresh session, an active subscription or when the node is + * disabled/stopped. Drives {@link NodeConnectionState.WaitingForDeviceDiscovery}. + */ + likelyOffline = false; } export class State extends NetworkBehavior.State { @@ -331,6 +406,7 @@ export namespace NetworkClient { export class Events extends NetworkBehavior.Events { autoSubscribe$Changed = new Observable<[value: boolean, oldValue: boolean]>(); defaultSubscription$Changed = new Observable<[value: Subscribe | undefined, oldValue: Subscribe | undefined]>(); + isDisabled$Changed = new Observable<[value: boolean, oldValue: boolean]>(); subscriptionStatusChanged = new Observable<[isActive: boolean]>(); subscriptionAlive = new Observable<[]>(); } diff --git a/packages/node/src/behavior/system/network/NetworkServer.ts b/packages/node/src/behavior/system/network/NetworkServer.ts index a782ff300c..ec05a7a6e9 100644 --- a/packages/node/src/behavior/system/network/NetworkServer.ts +++ b/packages/node/src/behavior/system/network/NetworkServer.ts @@ -168,6 +168,20 @@ export namespace NetworkServer { */ transportPreference?: "tcp" | "udp"; + /** + * Controller-level connection policy for commissioned peers. + * + * When true (default), the node auto-connects every commissioned, non-disabled peer as it goes online — the + * right choice for a headless controller or bridge that wants all peers live. + * + * When false, no peer is connected on online and the consumer starts peers on demand (`ClientNode.start()` / + * `ClientNode.enable()`). Use this for interactive or debug controllers that manage connections themselves + * (e.g. the nodejs shell). This is orthogonal to per-node `ClientNode.disable()`: this flag is a controller + * policy that leaves peers enabled, whereas disabling a peer is a persisted per-node "this node is off" state + * (a seasonal device, say) that the bulk connect skips regardless of this flag. + */ + autoStartCommissionedPeers = true; + /** * Network profile describing our own (local) network — the sender-side MRP additive-delay * axis. Defaults to "fast"; set to "thread" on a Thread device. diff --git a/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts b/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts index a6f84cae90..62ed82f47f 100644 --- a/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts +++ b/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts @@ -18,6 +18,7 @@ import { NetworkInterfaceDetailed, NoAddressAvailableError, ObserverGroup, + STANDARD_MATTER_PORT, Transport, TransportSet, UdpTransport, @@ -316,6 +317,13 @@ export class ServerNetworkRuntime extends NetworkRuntime { const { owner } = this; const { env } = owner; + // A commissionable node binds the well-known Matter port so commissioners find it at a known location. A node + // with commissioning disabled (e.g. a controller) uses an ephemeral port rather than squatting 5540. An + // explicit port is always honored. + if (owner.state.network.port === undefined && owner.state.commissioning.enabled !== false) { + await owner.set({ network: { port: STANDARD_MATTER_PORT } }); + } + // Configure network const interfaces = env.get(TransportSet); await this.addTransports(interfaces); diff --git a/packages/node/src/endpoint/properties/Behaviors.ts b/packages/node/src/endpoint/properties/Behaviors.ts index 30f854701c..5646adb59c 100644 --- a/packages/node/src/endpoint/properties/Behaviors.ts +++ b/packages/node/src/endpoint/properties/Behaviors.ts @@ -5,7 +5,7 @@ */ import { Behavior } from "#behavior/Behavior.js"; -import type { ClusterBehavior } from "#behavior/cluster/ClusterBehavior.js"; +import { ClusterBehavior } from "#behavior/cluster/ClusterBehavior.js"; import { ActionContext } from "#behavior/context/ActionContext.js"; import { NodeActivity } from "#behavior/context/NodeActivity.js"; import { LocalActorContext } from "#behavior/context/server/LocalActorContext.js"; @@ -31,7 +31,7 @@ import { } from "@matter/general"; import { ClusterModel } from "@matter/model"; import { ClusterTypeProtocol, Val } from "@matter/protocol"; -import type { ClusterType } from "@matter/types"; +import type { ClusterId, ClusterType } from "@matter/types"; import type { Agent } from "../Agent.js"; import type { Endpoint } from "../Endpoint.js"; import { BehaviorInitializationError, EndpointBehaviorsError } from "../errors.js"; @@ -84,6 +84,18 @@ export class Behaviors { return supported as T; } + /** + * Obtain the {@link ClusterBehavior.Type} the endpoint uses to implement a given cluster, if supported. + */ + forCluster(clusterId: ClusterId): ClusterBehavior.Type | undefined { + for (const type of Object.values(this.#supported)) { + if (ClusterBehavior.is(type) && type.cluster.id === clusterId) { + return type; + } + } + return undefined; + } + /** * The list of active behaviors. */ diff --git a/packages/node/src/endpoint/properties/Endpoints.ts b/packages/node/src/endpoint/properties/Endpoints.ts index 44e9d33223..80fac07718 100644 --- a/packages/node/src/endpoint/properties/Endpoints.ts +++ b/packages/node/src/endpoint/properties/Endpoints.ts @@ -42,7 +42,7 @@ export class Endpoints implements ImmutableSet { } get size(): number { - return this.#list.length + 1; + return this.#list.length; } map(mapper: (item: Endpoint) => R): R[] { diff --git a/packages/node/src/node/ClientNode.ts b/packages/node/src/node/ClientNode.ts index f43175087f..e9cf4e78e5 100644 --- a/packages/node/src/node/ClientNode.ts +++ b/packages/node/src/node/ClientNode.ts @@ -22,6 +22,7 @@ import { ServerNodeStore } from "#storage/server/ServerNodeStore.js"; import { DependencyLifecycleError, Diagnostic, + Duration, Identity, ImplementationError, InternalError, @@ -33,6 +34,7 @@ import { Matter, MatterModel } from "@matter/model"; import { Interactable, OccurrenceManager, PeerAddress, PeerSet } from "@matter/protocol"; import { ClientEndpointInitializer } from "./client/ClientEndpointInitializer.js"; import { ClientNodeInteraction } from "./client/ClientNodeInteraction.js"; +import { ClientNodeLifecycle } from "./ClientNodeLifecycle.js"; import { Node } from "./Node.js"; import type { ServerNode } from "./ServerNode.js"; @@ -81,6 +83,10 @@ export class ClientNode extends Node { return this.#matter ?? this.env.maybeGet(MatterModel) ?? Matter; } + override get lifecycle(): ClientNodeLifecycle { + return super.lifecycle as ClientNodeLifecycle; + } + override get endpoints(): ClientNodeEndpoints { return new ClientNodeEndpoints(this); } @@ -153,6 +159,25 @@ export class ClientNode extends Node { await this.delete(); } + /** + * Open a Basic Commissioning Window on this peer (uses the passcode originally printed on the device). + * + * This is an optional feature and not all devices support it; use {@link openEnhancedCommissioningWindow} unless + * you specifically need the basic commissioning method. + */ + async openBasicCommissioningWindow(commissioningTimeout?: Duration) { + await this.act(agent => agent.commissioning.openBasicCommissioningWindow(commissioningTimeout)); + } + + /** + * Open an Enhanced Commissioning Window on this peer using a freshly generated random passcode. + * + * @returns the manual and QR pairing codes encoding the generated passcode. + */ + async openEnhancedCommissioningWindow(commissioningTimeout?: Duration) { + return await this.act(agent => agent.commissioning.openEnhancedCommissioningWindow(commissioningTimeout)); + } + /** * Force-remove the node without first decommissioning. * @@ -185,7 +210,12 @@ export class ClientNode extends Node { return; } + // A disabled node should be offline; setting this before teardown also lets the connection-state engine settle + // straight to Disconnected instead of flashing Reconnecting as the subscription drops. + this.lifecycle.targetState = "offline"; await this.lifecycle.mutex.produce(async () => { + // Drop the subscription before flipping isDisabled so the connection-state engine cannot momentarily read + // Connected while disabling (subscriptionActive is checked ahead of isDisabled). await this.cancelWithMutex(); await this.setStateOf(NetworkClient, { isDisabled: true }); }); @@ -217,6 +247,10 @@ export class ClientNode extends Node { await super.resetWithMutex(); } + protected override createLifecycle(): ClientNodeLifecycle { + return new ClientNodeLifecycle(this); + } + protected createRuntime(): NetworkRuntime { return new ClientNetworkRuntime(this); } diff --git a/packages/node/src/node/ClientNodeLifecycle.ts b/packages/node/src/node/ClientNodeLifecycle.ts new file mode 100644 index 0000000000..30354bfe54 --- /dev/null +++ b/packages/node/src/node/ClientNodeLifecycle.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ActionContext } from "#behavior/context/ActionContext.js"; +import { Observable } from "@matter/general"; +import { NodeLifecycle } from "./NodeLifecycle.js"; + +/** + * Connection state of a client (peer) node. + * + * Values match the legacy `NodeStates` enum so consumers migrate by type swap. + */ +export enum NodeConnectionState { + /** + * The node is connected: the last communications succeeded and subscription updates are flowing, so data is + * up-to-date. + */ + Connected = 0, + + /** + * The node is disconnected because it is disabled, stopped or not yet started. No connection is attempted and + * data is stale. + */ + Disconnected = 1, + + /** + * A former connection was lost and we are actively re-establishing it on known addresses. Data is stale and it is + * not yet known whether reconnection will succeed. + */ + Reconnecting = 2, + + /** + * The node appears offline: communication failed and there is no known-reachable address, so we are waiting for the + * node to re-announce itself via mDNS before attempting to connect again. + */ + WaitingForDeviceDiscovery = 3, +} + +/** + * Lifecycle information specific to client (peer) nodes. + */ +export class ClientNodeLifecycle extends NodeLifecycle { + #seeded = Observable<[context: ActionContext]>(); + #connectionStateChanged = Observable<[state: NodeConnectionState]>(); + #isSeeded = false; + #connectionState = NodeConnectionState.Disconnected; + + /** + * True once the node's structure has been read at least once (BasicInformation present and more than just the + * root endpoint present). + */ + get isSeeded() { + return this.#isSeeded; + } + + /** + * Emits the first time the node becomes {@link isSeeded}. + */ + get seeded() { + return this.#seeded; + } + + /** + * Transitions the node to seeded. No-op if already seeded, so {@link seeded} emits at most once. + */ + markSeeded(context: ActionContext) { + if (this.#isSeeded) { + return; + } + this.#isSeeded = true; + this.#seeded.emit(context); + } + + /** + * The current {@link NodeConnectionState}. + */ + get connectionState() { + return this.#connectionState; + } + + /** + * Emits on each {@link connectionState} transition (not on no-op re-computes). + */ + get connectionStateChanged() { + return this.#connectionStateChanged; + } + + /** + * True when {@link connectionState} is {@link NodeConnectionState.Connected}. + */ + get isConnected() { + return this.#connectionState === NodeConnectionState.Connected; + } + + /** + * Set the {@link connectionState}, emitting {@link connectionStateChanged} only on a real transition. + */ + setConnectionState(state: NodeConnectionState) { + if (this.#connectionState === state) { + return; + } + this.#connectionState = state; + this.#connectionStateChanged.emit(state); + } +} diff --git a/packages/node/src/node/client/Peers.ts b/packages/node/src/node/client/Peers.ts index c403bf5948..69ab3a8fd0 100644 --- a/packages/node/src/node/client/Peers.ts +++ b/packages/node/src/node/client/Peers.ts @@ -15,6 +15,7 @@ import { InstanceDiscovery } from "#behavior/system/controller/discovery/Instanc import { PaseDiscovery } from "#behavior/system/controller/discovery/PaseDiscovery.js"; import { IcdClient } from "#behavior/system/icd/IcdClient.js"; import { NetworkClient } from "#behavior/system/network/NetworkClient.js"; +import { NetworkServer } from "#behavior/system/network/NetworkServer.js"; import { BasicInformationClient } from "#behaviors/basic-information"; import { BridgedDeviceBasicInformationClient } from "#behaviors/bridged-device-basic-information"; import { IcdManagementClient } from "#behaviors/icd-management"; @@ -26,6 +27,7 @@ import { ClientGroup } from "#node/ClientGroup.js"; import { InteractionServer } from "#node/server/InteractionServer.js"; import { ServerNodeStore } from "#storage/server/ServerNodeStore.js"; import { + Bytes, CancelablePromise, Diagnostic, Duration, @@ -43,16 +45,21 @@ import { UninitializedDependencyError, } from "@matter/general"; import { + ClientInteraction, ClientSubscriptionHandler, ClientSubscriptions, CommissioningError, + DiscoveryData, + FabricAuthority, FabricManager, + Invoke, Peer, PeerAddress, PeerLeftError, SessionManager, } from "@matter/protocol"; -import { FabricIndex } from "@matter/types"; +import { FabricIndex, NodeId, Status } from "@matter/types"; +import { GeneralCommissioning } from "@matter/types/clusters/general-commissioning"; import { ClientNode } from "../ClientNode.js"; import type { ServerNode } from "../ServerNode.js"; import { ClientNodeFactory } from "./ClientNodeFactory.js"; @@ -74,6 +81,8 @@ export class Peers extends EndpointContainer { #mutex = new Mutex(this); #closed = false; #commissioning = new Set(); + #instrumented = new WeakSet(); + #bridgedInstrumented = new WeakSet(); constructor(owner: ServerNode) { super(owner); @@ -124,15 +133,17 @@ export class Peers extends EndpointContainer { } async #nodeOnline() { - for (const peer of this) { - if (!peer.lifecycle.isCommissioned || peer.maybeStateOf("network")?.isDisabled) { - continue; - } - try { - await peer.start(); - } catch (e) { - MatterError.accept(e); - logger.warn(`Error starting peer ${peer}:`, e); + if (this.owner.stateOf(NetworkServer).autoStartCommissionedPeers) { + for (const peer of this) { + if (!peer.lifecycle.isCommissioned || peer.maybeStateOf("network")?.isDisabled) { + continue; + } + try { + await peer.start(); + } catch (e) { + MatterError.accept(e); + logger.warn(`Error starting peer ${peer}:`, e); + } } } this.#manageExpiration(); @@ -169,7 +180,7 @@ export class Peers extends EndpointContainer { * Find a specific commissionable node and commission. */ commission(options: CommissioningDiscovery.Options) { - return new CommissioningDiscovery(this.owner as ServerNode, options); + return new CommissioningDiscovery(this.owner, options); } /** @@ -179,7 +190,135 @@ export class Peers extends EndpointContainer { * performs the commissioning flow, or for raw PASE channel establishment. */ pase(options: PaseDiscovery.Options) { - return new PaseDiscovery(this.owner as ServerNode, options); + return new PaseDiscovery(this.owner, options); + } + + /** + * Complete a commissioning another commissioner started and handed off before the operational (CASE) step + * (see {@link CommissioningClient.CommissioningOptions.finalizeCommissioning}). Operationally discovers and + * connects the node, sends {@link GeneralCommissioning} `CommissioningComplete` to disarm the failsafe and + * finalize, reads the node's structure and (unless `autoSubscribe` is false) subscribes — exactly as + * {@link commission} does, so the peer is seeded rather than a blind commissioned node — then registers it. + * `discoveryData` (from the hand-off) seeds operational discovery; `options` mirror the matching + * {@link commission} options. Throws {@link CommissioningError} and removes the peer entry if discovery, + * connection, or `CommissioningComplete` fails. + */ + async completeCommissioning( + nodeId: NodeId, + discoveryData?: DiscoveryData, + options?: Pick< + CommissioningClient.CommissioningOptions, + "autoSubscribe" | "defaultSubscription" | "autoStateInitialize" + >, + ): Promise { + // Split commissioning can only finalize over THE fabric the initiating commissioner established (the one the + // device's NOC belongs to). Require it to already exist rather than auto-creating an empty fabric, which would + // mask a misconfigured controller that can never succeed. Match by CA root certificate, as FabricAuthority does. + const fabricAuthority = await this.owner.env.load(FabricAuthority); + const fabric = fabricAuthority.fabrics.find(f => Bytes.areEqual(f.rootCert, fabricAuthority.ca.rootCert)); + if (fabric === undefined) { + throw new ImplementationError( + `Cannot complete commissioning for ${nodeId}: the controller does not hold the shared fabric it must be finalized on (import it first)`, + ); + } + + const address = fabric.addressOf(nodeId); + + // forAddress() below returns the existing node for an already-commissioned address rather than creating one, + // and the catch block deletes that node on any failure (including a non-throwing CommissioningComplete + // errorCode). Without this guard a second completeCommissioning() call for the same peer — e.g. a duplicate + // hand-off or a retry — would resolve to the live peer and could delete it. Must check before forAddress() + // since that call sets peerAddress, which flips lifecycle.isCommissioned true immediately. + const existing = this.get(address); + if (existing?.lifecycle.isCommissioned) { + throw new ImplementationError(`${existing} is already commissioned`); + } + + // Both-or-nothing: persist the fabric before forAddress() durably writes the node, so a persist failure aborts + // cleanly and never leaves a node referencing an unpersisted fabric. + await fabric.persist(); + + const node = await this.forAddress(address); + + // Serialize like commission() so parallel finalize attempts on the same node cannot both send + // CommissioningComplete (the loser races on device failsafe state and would delete the winner's node). + return this.runCommissioning(node, async () => { + try { + if (discoveryData !== undefined) { + // forAddress() already bound the live Peer (CommissioningClient#bindPeer fires as soon as + // peerAddress is set), snapshotting an empty descriptor. Seed the Peer directly so operational + // discovery sees it; writing CommissioningClient.descriptor only updates state that nothing + // re-reads. + node.env.get(Peer).descriptor.discoveryData = discoveryData; + } + + // Mirror commission()'s post-commission setup so start() reads the node's structure (latching + // `seeded`) and, unless opted out, establishes the sustained subscription — otherwise the finalized + // peer would be commissioned but blind, and `lifecycle.seeded` would never emit. + await node.act(agent => { + const network = agent.get(NetworkClient); + network.state.defaultSubscription = options?.defaultSubscription; + network.state.autoSubscribe = options?.autoSubscribe !== false; + network.state.autoStateInitialize = options?.autoStateInitialize; + network.internal.isNewlyCommissioned = true; + }); + + await node.start(); + + // Low-level invoke so the finalize round-trip gets the extended failsafe response timeout, exactly as + // ControllerCommissioningFlow does; the generated command method cannot pass invoke options. + const invoke = Invoke({ + commands: [ + Invoke.ConcreteCommandRequest({ + endpoint: node, + cluster: GeneralCommissioning, + command: "commissioningComplete", + fields: undefined, + }), + ], + useExtendedFailSafeMessageResponseTimeout: true, + }); + + let response: GeneralCommissioning.CommissioningCompleteResponse | undefined; + for await (const chunk of (node.interaction as ClientInteraction).invoke(invoke)) { + for (const entry of chunk) { + if (entry.kind === "cmd-status" && entry.status !== Status.Success) { + throw new CommissioningError( + `CommissioningComplete failed for ${nodeId}: status ${entry.status}`, + ); + } + if (entry.kind === "cmd-response") { + // DecodedCommandResponse.data is `any`; CommissioningComplete answers with errorCode/debugText. + response = entry.data as GeneralCommissioning.CommissioningCompleteResponse; + } + } + } + + if (response === undefined) { + throw new CommissioningError(`CommissioningComplete for ${nodeId} returned no response`); + } + if (response.errorCode !== GeneralCommissioning.CommissioningError.Ok) { + throw new CommissioningError( + `CommissioningComplete failed for ${nodeId}: ${response.errorCode} ${response.debugText}`, + ); + } + } catch (error) { + // lifecycle.isCommissioned flips true the moment forAddress() sets peerAddress, before start() or + // CommissioningComplete run, so any failure up to this point (thrown or via CommissioningError above) + // would otherwise leave a phantom commissioned-looking node behind. Once CommissioningComplete + // returns Ok below, the device has genuinely disarmed its failsafe and joined the fabric, so a + // failure persisting that fact locally must NOT delete the node. + try { + await node.delete(); + } catch (deleteError) { + logger.warn(`Error deleting ${node} after failed commissioning completion:`, deleteError); + } + throw error; + } + + await node.setStateOf(CommissioningClient, { commissionedAt: Time.nowMs }); + return node; + }); } /** @@ -221,14 +360,20 @@ export class Peers extends EndpointContainer { return this.owner.env.get(ClientStructureEvents).clusterInstalled(type); } + /** Commissioned operational peers. Excludes commissionable discoveries and group nodes. */ + get commissioned(): ClientNode[] { + return [...this].filter(node => node.nodeType === "client" && node.lifecycle.isCommissioned); + } + /** - * Emits when fixed attributes + * Look up a peer by numeric/string id or {@link PeerAddress}. A {@link PeerAddress} matches the commissioned + * peer whose {@link CommissioningClient} peer address equals it; otherwise the container's id lookup is used. */ override get(id: number | string | PeerAddress) { if (typeof id !== "string" && typeof id !== "number") { const address = PeerAddress(id); for (const node of this) { - if (node.behaviors.active.some(({ id }) => id === "commissioning")) { + if (node.behaviors.active.some(behavior => behavior.id === "commissioning")) { const nodeAddress = node.maybeStateOf("commissioning")?.peerAddress as PeerAddress | undefined; if (nodeAddress !== undefined && PeerAddress.is(nodeAddress, address)) { return node; @@ -482,10 +627,46 @@ export class Peers extends EndpointContainer { return; } + // clusterInstalled re-emits per node (endpoint-install, cluster-add, structure rebuilds), so register the + // device-event handlers below once per node to avoid duplicate side effects on repeat emits. + if (this.#instrumented.has(node)) { + this.#evaluateSeeded(node); + return; + } + this.#instrumented.add(node); + node.eventsOf(type).leave?.on(({ fabricIndex }) => this.#onLeave(node, fabricIndex)); node.eventsOf(type).shutDown?.on(() => this.#onShutdown(node)); node.eventsOf(type).startUp?.on(() => this.#onStartUp(node)); node.eventsOf(type).configurationVersion$Changed?.on(() => node.lifecycle.configurationVersionChanged.emit()); + + this.#evaluateSeeded(node); + if (!node.lifecycle.isSeeded) { + // Self-disposing: removes itself once seeding latches so no dead listener persists for the node's + // remaining lifetime. Safe to call off() from within this callback because Observable#emit iterates a + // snapshot of its observers. + const onChanged = () => { + this.#evaluateSeeded(node); + if (node.lifecycle.isSeeded) { + node.lifecycle.changed.off(onChanged); + } + }; + node.lifecycle.changed.on(onChanged); + } + } + + /** + * A node is seeded once its structure has been read at least once: BasicInformation is present and at least one + * endpoint beyond the root is present. Re-evaluated on BasicInformation install and on any endpoint tree change. + */ + #evaluateSeeded(node: ClientNode) { + if (node.lifecycle.isSeeded) { + return; + } + if (node.maybeStateOf(BasicInformationClient) === undefined || node.endpoints.size <= 1) { + return; + } + node.lifecycle.markSeeded(LocalActorContext.ReadOnly); } /** @@ -493,6 +674,12 @@ export class Peers extends EndpointContainer { * through the same endpoint lifecycle event used for the node's `BasicInformation`. */ #instrumentBridgedConfigurationVersion(endpoint: Endpoint, type: typeof BridgedDeviceBasicInformationClient) { + // clusterInstalled re-emits per endpoint; register once + if (this.#bridgedInstrumented.has(endpoint)) { + return; + } + this.#bridgedInstrumented.add(endpoint); + endpoint .eventsOf(type) .configurationVersion$Changed?.on(() => endpoint.lifecycle.configurationVersionChanged.emit()); @@ -538,7 +725,7 @@ export class Peers extends EndpointContainer { // The reason is that we saw such cases and should prevent discarding a node directly after commissioning. // This solution still has some holes that could prevent removing nodes automatically, but best-effort // variant for now until we know how often that happens in practice. - if (!node.act(agent => agent.get(NetworkClient).subscriptionActive)) { + if (!(await node.act(agent => agent.get(NetworkClient).subscriptionActive))) { logger.info( "Leave event for peer", Diagnostic.strong(node.id), @@ -565,7 +752,7 @@ export class Peers extends EndpointContainer { // Ignore shutdown events received during initial subscription establishment as they may be stale events // from before the device was restarted. This mirrors the same guard in #onLeave. - if (!node.act(agent => agent.get(NetworkClient).subscriptionActive)) { + if (!(await node.act(agent => agent.get(NetworkClient).subscriptionActive))) { logger.debug( "Shutdown event for peer", Diagnostic.strong(node.id), @@ -595,7 +782,7 @@ export class Peers extends EndpointContainer { // Ignore startup events received during the initial subscription establishment // as they may be stale events from before the device was restarted. - if (!node.act(agent => agent.get(NetworkClient).subscriptionActive)) { + if (!(await node.act(agent => agent.get(NetworkClient).subscriptionActive))) { logger.debug( "Startup event for peer", Diagnostic.strong(node.id), @@ -629,19 +816,15 @@ class Factory extends ClientNodeFactory { create(options: ClientNode.Options, peerAddress?: PeerAddress) { let node: ClientNode; if (peerAddress !== undefined && PeerAddress.isGroup(peerAddress)) { - if (options.id === undefined) { - options.id = `group${++this.#groupIdCounter}`; - } node = new ClientGroup({ ...options, + id: options.id ?? `group${++this.#groupIdCounter}`, owner: this.#owner.owner, }); } else { - if (options.id === undefined) { - options.id = this.#owner.owner.env.get(ServerNodeStore).clientStores.allocateId(); - } node = new ClientNode({ ...options, + id: options.id ?? this.#owner.owner.env.get(ServerNodeStore).clientStores.allocateId(), owner: this.#owner.owner, }); } diff --git a/packages/node/src/node/index.ts b/packages/node/src/node/index.ts index 39796cae4e..489414924e 100644 --- a/packages/node/src/node/index.ts +++ b/packages/node/src/node/index.ts @@ -6,6 +6,7 @@ export * from "./client/index.js"; export * from "./ClientNode.js"; +export * from "./ClientNodeLifecycle.js"; export * from "./integration/index.js"; export * from "./Node.js"; export * from "./NodeLifecycle.js"; diff --git a/packages/node/test/behavior/system/commissioning/CommissioningWindowTest.ts b/packages/node/test/behavior/system/commissioning/CommissioningWindowTest.ts new file mode 100644 index 0000000000..d89df15d26 --- /dev/null +++ b/packages/node/test/behavior/system/commissioning/CommissioningWindowTest.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AdministratorCommissioningClient, + AdministratorCommissioningServer, +} from "#behaviors/administrator-commissioning"; +import type { ClientNode } from "#node/ClientNode.js"; +import { Bytes, ImplementationError } from "@matter/general"; +import { QrPairingCodeCodec } from "@matter/types"; +import { AdministratorCommissioning } from "@matter/types/clusters/administrator-commissioning"; +import { MockServerNode } from "../../../node/mock-server-node.js"; +import { MockSite } from "../../../node/mock-site.js"; +import { subscribedPeer } from "../../../node/node-helpers.js"; + +/** + * Replace `openCommissioningWindow` on the runtime prototype of the peer's AdministratorCommissioningClient facade, + * capturing every call and delegating to the original implementation so the device's window still actually opens. + */ +async function spyOnOpenCommissioningWindow(peer: ClientNode) { + const proto = await peer.act(agent => Object.getPrototypeOf(agent.get(AdministratorCommissioningClient))); + const original = proto.openCommissioningWindow; + const calls = new Array(); + proto.openCommissioningWindow = async function ( + this: unknown, + request: AdministratorCommissioning.OpenCommissioningWindowRequest, + ) { + calls.push(request); + return original.call(this, request); + }; + return { + calls, + restore: () => { + proto.openCommissioningWindow = original; + }, + }; +} + +/** Same as {@link spyOnOpenCommissioningWindow} but for `openBasicCommissioningWindow`. */ +async function spyOnOpenBasicCommissioningWindow(peer: ClientNode) { + const proto = await peer.act(agent => Object.getPrototypeOf(agent.get(AdministratorCommissioningClient))); + const original = proto.openBasicCommissioningWindow; + const calls = new Array(); + proto.openBasicCommissioningWindow = async function ( + this: unknown, + request: AdministratorCommissioning.OpenBasicCommissioningWindowRequest, + ) { + calls.push(request); + return original.call(this, request); + }; + return { + calls, + restore: () => { + proto.openBasicCommissioningWindow = original; + }, + }; +} + +/** Root endpoint type for a device that advertises the Basic Commissioning feature. */ +const BasicCapableRootEndpoint = MockServerNode.RootEndpoint.with(AdministratorCommissioningServer.with("Basic")); + +describe("CommissioningClient commissioning-window helpers", () => { + before(() => { + MockTime.init(); + }); + + describe("openEnhancedCommissioningWindow", () => { + it("opens a window with a generated verifier and returns pairing codes", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + const peer = await subscribedPeer(controller, "peer1"); + + const spy = await spyOnOpenCommissioningWindow(peer); + try { + const result = await MockTime.resolve(peer.openEnhancedCommissioningWindow()); + + expect(typeof result.manualPairingCode).equals("string"); + expect(typeof result.qrPairingCode).equals("string"); + + expect(spy.calls.length).equals(1); + const [request] = spy.calls; + expect(Bytes.of(request.pakePasscodeVerifier).length).greaterThan(0); + expect(Bytes.of(request.salt).length).greaterThan(0); + + // The QR code preserves the full discriminator (the manual code only carries a truncated form), so + // decode it to confirm the command was invoked with the same generated discriminator we return. + const [decodedQrData] = QrPairingCodeCodec.decode(result.qrPairingCode); + expect(decodedQrData.discriminator).equals(request.discriminator); + } finally { + spy.restore(); + } + }); + + it("tolerates a WindowNotOpen error on the pre-emptive revoke", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + const peer = await subscribedPeer(controller, "peer1"); + + // No window has ever been opened, so the device rejects the pre-emptive revoke with WindowNotOpen. This + // exercises the tolerance path without needing to fake the error. + await MockTime.resolve(peer.openEnhancedCommissioningWindow()); + }); + }); + + describe("openBasicCommissioningWindow", () => { + it("throws ImplementationError when the peer does not support the basic feature", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + const peer = await subscribedPeer(controller, "peer1"); + + await expect(MockTime.resolve(peer.openBasicCommissioningWindow())).rejectedWith(ImplementationError); + }); + + it("invokes openBasicCommissioningWindow when the peer supports the basic feature", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair({ device: { type: BasicCapableRootEndpoint } }); + const peer = await subscribedPeer(controller, "peer1"); + + const spy = await spyOnOpenBasicCommissioningWindow(peer); + try { + await MockTime.resolve(peer.openBasicCommissioningWindow()); + expect(spy.calls.length).equals(1); + expect(spy.calls[0].commissioningTimeout).equals(900); + } finally { + spy.restore(); + } + }); + + it("tolerates a WindowNotOpen error on the pre-emptive revoke", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair({ device: { type: BasicCapableRootEndpoint } }); + const peer = await subscribedPeer(controller, "peer1"); + + await MockTime.resolve(peer.openBasicCommissioningWindow()); + }); + }); +}); diff --git a/packages/node/test/behavior/system/network/ConnectionStateTest.ts b/packages/node/test/behavior/system/network/ConnectionStateTest.ts new file mode 100644 index 0000000000..2723be7d93 --- /dev/null +++ b/packages/node/test/behavior/system/network/ConnectionStateTest.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IcdClient } from "#behavior/system/icd/IcdClient.js"; +import { NetworkClient } from "#behavior/system/network/NetworkClient.js"; +import { IcdManagementServer } from "#behaviors/icd-management"; +import { ClientNode } from "#node/ClientNode.js"; +import { NodeConnectionState } from "#node/ClientNodeLifecycle.js"; +import { ServerNode } from "#node/ServerNode.js"; +import { Crypto, MockCrypto } from "@matter/general"; +import { Peer, SustainedSubscription } from "@matter/protocol"; +import { MockSite } from "../../../node/mock-site.js"; +import { subscribedPeer } from "../../../node/node-helpers.js"; + +const RootWithIcd = ServerNode.RootEndpoint.with(IcdManagementServer); + +/** Drive the peer offline (subscription loses liveness) and settle the resulting recompute. */ +async function loseSubscription(peer: ClientNode, device: ServerNode) { + const subscription = peer.behaviors.internalsOf(NetworkClient).activeSubscription!; + SustainedSubscription.assert(subscription); + await MockTime.resolve(device.stop()); + await MockTime.resolve(subscription.inactive); + await MockTime.resolve(Promise.resolve(), { macrotasks: true }); + return subscription; +} + +describe("ConnectionState", () => { + before(() => { + MockTime.init(); + }); + + it("reports Connected with an active subscription", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + const peer1 = await subscribedPeer(controller, "peer1"); + + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.Connected); + expect(peer1.lifecycle.isConnected).true; + }); + + it("transitions to Reconnecting when the subscription is lost", async () => { + await using site = new MockSite(); + const { controller, device } = await site.addCommissionedPair(); + const peer1 = await subscribedPeer(controller, "peer1"); + + await loseSubscription(peer1, device); + + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.Reconnecting); + expect(peer1.lifecycle.isConnected).false; + }); + + it("transitions to WaitingForDeviceDiscovery on an establishment-unresponsive signal", async () => { + await using site = new MockSite(); + const { controller, device } = await site.addCommissionedPair(); + const peer1 = await subscribedPeer(controller, "peer1"); + + await loseSubscription(peer1, device); + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.Reconnecting); + + peer1.env.get(Peer).establishmentUnresponsive.emit(); + await MockTime.resolve(Promise.resolve(), { macrotasks: true }); + + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.WaitingForDeviceDiscovery); + expect(peer1.lifecycle.isConnected).false; + }); + + it("emits connectionStateChanged once per transition and is idempotent under repeated signals", async () => { + await using site = new MockSite(); + const { controller, device } = await site.addCommissionedPair(); + const peer1 = await subscribedPeer(controller, "peer1"); + + const states = new Array(); + peer1.lifecycle.connectionStateChanged.on(state => void states.push(state)); + + await loseSubscription(peer1, device); + expect(states).deep.equals([NodeConnectionState.Reconnecting]); + + const protopeer = peer1.env.get(Peer); + protopeer.establishmentUnresponsive.emit(); + await MockTime.resolve(Promise.resolve(), { macrotasks: true }); + expect(states).deep.equals([NodeConnectionState.Reconnecting, NodeConnectionState.WaitingForDeviceDiscovery]); + + // A repeated establishment-unresponsive emit must not re-emit the transition (idempotent latch). + protopeer.establishmentUnresponsive.emit(); + protopeer.establishmentUnresponsive.emit(); + await MockTime.resolve(Promise.resolve(), { macrotasks: true }); + expect(states).deep.equals([NodeConnectionState.Reconnecting, NodeConnectionState.WaitingForDeviceDiscovery]); + }); + + it("recovers WaitingForDeviceDiscovery -> Reconnecting -> Connected", async () => { + await using site = new MockSite(); + const { controller, device } = await site.addCommissionedPair(); + const peer1 = await subscribedPeer(controller, "peer1"); + + const subscription = await loseSubscription(peer1, device); + peer1.env.get(Peer).establishmentUnresponsive.emit(); + await MockTime.resolve(Promise.resolve(), { macrotasks: true }); + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.WaitingForDeviceDiscovery); + + const states = new Array(); + peer1.lifecycle.connectionStateChanged.on(state => void states.push(state)); + + const crypto = device.env.get(Crypto) as MockCrypto; + crypto.entropic = true; + await MockTime.resolve(device.start()); + await MockTime.resolve(subscription.active); + crypto.entropic = false; + + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.Connected); + expect(peer1.lifecycle.isConnected).true; + expect(states).contains(NodeConnectionState.Reconnecting); + expect(states[states.length - 1]).equals(NodeConnectionState.Connected); + }); + + it("transitions to Disconnected when disabled", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + const peer1 = await subscribedPeer(controller, "peer1"); + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.Connected); + + // Disabling goes straight to Disconnected without a spurious Reconnecting flash as the subscription drops. + const states = new Array(); + peer1.lifecycle.connectionStateChanged.on(state => void states.push(state)); + + await MockTime.resolve(peer1.disable(), { macrotasks: true }); + + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.Disconnected); + expect(peer1.lifecycle.isConnected).false; + expect(states).deep.equals([NodeConnectionState.Disconnected]); + }); + + it("transitions to Disconnected when the node is stopped", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + const peer1 = await subscribedPeer(controller, "peer1"); + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.Connected); + + await MockTime.resolve(peer1.stop(), { macrotasks: true }); + + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.Disconnected); + expect(peer1.lifecycle.isConnected).false; + }); + + it("reports Disconnected for a known peer that has not been started", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + await subscribedPeer(controller, "peer1"); + + // Reload the controller so the peer is known from storage but never started; a not-started node must read the + // initial Disconnected, not the enum's numeric default 0 (== Connected). + await MockTime.resolve(controller.close()); + const controller2 = await site.addController({ index: 1 }); + + const peer = controller2.peers.get("peer1")!; + expect(peer).not.undefined; + + expect(peer.lifecycle.connectionState).equals(NodeConnectionState.Disconnected); + expect(peer.lifecycle.isConnected).false; + }); + + it("transitions to WaitingForDeviceDiscovery when a registered ICD misses its check-in", async () => { + await using site = new MockSite(); + const { controller, device } = await site.addCommissionedPair({ + device: { type: RootWithIcd }, + }); + const peer1 = await subscribedPeer(controller, "peer1"); + expect(peer1.behaviors.has(IcdClient)).true; + + await loseSubscription(peer1, device); + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.Reconnecting); + + peer1.eventsOf(IcdClient).checkInMissed.emit(); + await MockTime.resolve(Promise.resolve(), { macrotasks: true }); + + expect(peer1.lifecycle.connectionState).equals(NodeConnectionState.WaitingForDeviceDiscovery); + }); +}); diff --git a/packages/node/test/endpoint/properties/BehaviorsForClusterTest.ts b/packages/node/test/endpoint/properties/BehaviorsForClusterTest.ts new file mode 100644 index 0000000000..53ead2d293 --- /dev/null +++ b/packages/node/test/endpoint/properties/BehaviorsForClusterTest.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Behavior } from "#behavior/Behavior.js"; +import { ClusterBehavior } from "#behavior/cluster/ClusterBehavior.js"; +import { OnOffClient } from "#behaviors/on-off"; +import { OnOffLightDevice } from "#devices/on-off-light"; +import { Endpoint } from "#endpoint/Endpoint.js"; +import { ClusterId } from "@matter/types"; +import { OnOff } from "@matter/types/clusters/on-off"; + +describe("Behaviors#forCluster", () => { + it("returns the client behavior type registered for a supported cluster", () => { + const endpoint = new Endpoint(OnOffLightDevice.withBehaviors(OnOffClient)); + expect(endpoint.behaviors.forCluster(OnOff.Cluster.id)).equal(OnOffClient); + }); + + it("returns the server behavior type registered for a supported cluster", () => { + const endpoint = new Endpoint(OnOffLightDevice); + const type = endpoint.behaviors.forCluster(OnOff.Cluster.id); + expect(type).not.undefined; + expect(ClusterBehavior.is(type!)).equal(true); + expect(type!.id).equal("onOff"); + }); + + it("returns undefined for a cluster the endpoint does not support", () => { + const endpoint = new Endpoint(OnOffLightDevice.withBehaviors(OnOffClient)); + expect(endpoint.behaviors.forCluster(ClusterId(0xffff, false))).undefined; + }); + + it("ignores supported non-cluster behaviors", () => { + class PlainBehavior extends Behavior { + static override readonly id = "plain"; + } + const endpoint = new Endpoint(OnOffLightDevice); + endpoint.behaviors.inject(PlainBehavior); + expect(endpoint.behaviors.forCluster(ClusterId(0xffff, false))).undefined; + }); +}); diff --git a/packages/node/test/node/ClientNodeTest.ts b/packages/node/test/node/ClientNodeTest.ts index d51067609a..e1b47f7557 100644 --- a/packages/node/test/node/ClientNodeTest.ts +++ b/packages/node/test/node/ClientNodeTest.ts @@ -2620,7 +2620,7 @@ const PEER1_STATE = { autoSubscribe: true, autoStateInitialize: undefined, isDisabled: false, - port: 0x15a4, + port: undefined, operationalPort: -1, defaultSubscription: undefined, maxEventNumber: 3n, diff --git a/packages/node/test/node/FailsafeCommissioningTest.ts b/packages/node/test/node/FailsafeCommissioningTest.ts index b186333fc9..bdfc91ee13 100644 --- a/packages/node/test/node/FailsafeCommissioningTest.ts +++ b/packages/node/test/node/FailsafeCommissioningTest.ts @@ -74,6 +74,16 @@ describe("Failsafe commissioning re-announcement", () => { */ class CommissioningAdSpy extends Advertiser { count = 0; + #announced?: () => void; + + // Resolves on the next commissioning advertisement after arm(). The re-announce is an async reaction to + // sessions.deleted, so tests must await this rather than a proxy signal (failsafe destroyed) that races it. + announced = Promise.resolve(); + + arm() { + this.count = 0; + this.announced = new Promise(resolve => (this.#announced = resolve)); + } protected getAdvertisement(_description: ServiceDescription) { return undefined; @@ -82,6 +92,7 @@ describe("Failsafe commissioning re-announcement", () => { override advertise(description: ServiceDescription, _event: Advertiser.BroadcastEvent) { if (ServiceDescription.isCommissioning(description)) { this.count++; + this.#announced?.(); } return undefined; } @@ -148,7 +159,7 @@ describe("Failsafe commissioning re-announcement", () => { const failsafeGone = waitForFailsafeDestroyed(commissioner); // Reset spy counter: we only care about advertisements triggered by the failsafe expiry. - adSpy.count = 0; + adSpy.arm(); // Advance mock time step-by-step until the failsafe fires and rollback() completes. // @@ -159,7 +170,9 @@ describe("Failsafe commissioning re-announcement", () => { // When the failsafe expires, rollback(): // 1. No addNOC → fabricIndex is undefined → fabric step skipped. // 2. closePaseSession() → sessions.deleted (isPase=true) → DeviceAdvertiser re-announces ✓. - await MockTime.resolve(failsafeGone, { macrotasks: true }); + // + // Await the advertisement itself, not just failsafeGone — see CommissioningAdSpy.announced (races otherwise). + await MockTime.resolve(Promise.all([failsafeGone, adSpy.announced]), { macrotasks: true }); disableEntropy(); @@ -259,11 +272,12 @@ describe("Failsafe commissioning re-announcement", () => { const failsafeGone = waitForFailsafeDestroyed(commissioner); // Reset spy counter: we only want to count announcements triggered by the failsafe expiry. - adSpy.count = 0; + adSpy.arm(); // Advance mock time step-by-step until the failsafe fires and rollback() completes. - // See scenario 1 comment above for why we use MockTime.resolve() rather than a single advance(). - await MockTime.resolve(failsafeGone, { macrotasks: true }); + // See scenario 1 comment above for why we use MockTime.resolve() rather than a single advance(), + // and why we await the advertisement itself alongside failsafeGone rather than failsafeGone alone. + await MockTime.resolve(Promise.all([failsafeGone, adSpy.announced]), { macrotasks: true }); disableEntropy(); @@ -303,12 +317,17 @@ describe("Failsafe commissioning re-announcement", () => { const adSpy = new CommissioningAdSpy(); device.env.get(DeviceAdvertiser).addAdvertiser(adSpy); + adSpy.arm(); + // Await the re-announcement alongside the rejected commission — see CommissioningAdSpy.announced. let caughtError: unknown; await MockTime.resolve( - controller.peers.commission({ passcode, discriminator, timeout: Seconds(90) }).catch(err => { - caughtError = err; - }), + Promise.all([ + controller.peers.commission({ passcode, discriminator, timeout: Seconds(90) }).catch(err => { + caughtError = err; + }), + adSpy.announced, + ]), { macrotasks: true }, ); diff --git a/packages/node/test/node/NodeLifecycleSeededTest.ts b/packages/node/test/node/NodeLifecycleSeededTest.ts new file mode 100644 index 0000000000..91796ed470 --- /dev/null +++ b/packages/node/test/node/NodeLifecycleSeededTest.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BasicInformationClient } from "#behaviors/basic-information"; +import { ClientStructureEvents } from "#node/client/ClientStructureEvents.js"; +import { ClientNodeLifecycle } from "#node/ClientNodeLifecycle.js"; +import { NodeLifecycle } from "#node/NodeLifecycle.js"; +import { MockSite } from "./mock-site.js"; + +describe("NodeLifecycle#isSeeded/seeded", () => { + before(() => { + MockTime.init(); + }); + + it("stays false without BasicInformation, false with only the root endpoint, then flips once an endpoint is added", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + + const peerAddress = controller.peers.get("peer1")!.peerAddress!; + await MockTime.resolve(controller.peers.get("peer1")!.delete()); + expect(controller.peers.size).equals(0); + + // A freshly recreated peer has no structure read yet. + const peer = await controller.peers.forAddress(peerAddress); + expect(peer.maybeStateOf(BasicInformationClient)).undefined; + expect(peer.endpoints.size).equals(1); + + let seededCount = 0; + peer.lifecycle.seeded.on(() => { + seededCount++; + }); + + expect(peer.lifecycle.isSeeded).false; + + // BasicInformation becomes available, but only the root endpoint is present. + peer.behaviors.require(BasicInformationClient); + controller.env.get(ClientStructureEvents).emitCluster(peer, BasicInformationClient); + + expect(peer.maybeStateOf(BasicInformationClient)).not.undefined; + expect(peer.endpoints.size).equals(1); + expect(peer.lifecycle.isSeeded).false; + expect(seededCount).equals(0); + + // An endpoint beyond the root is added to the peer's structure. + const ep1 = peer.endpoints.require(1); + expect(ep1).not.undefined; + + expect(peer.endpoints.size).equals(2); + expect(peer.lifecycle.isSeeded).true; + expect(seededCount).equals(1); + + // A further endpoint must not cause a second emission. + peer.endpoints.require(2); + expect(peer.endpoints.size).equals(3); + expect(seededCount).equals(1); + }); + + it("latches on BasicInformation install when endpoints are already present", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + + const peerAddress = controller.peers.get("peer1")!.peerAddress!; + await MockTime.resolve(controller.peers.get("peer1")!.delete()); + + const peer = await controller.peers.forAddress(peerAddress); + + let seededCount = 0; + peer.lifecycle.seeded.on(() => { + seededCount++; + }); + + // A non-root endpoint exists before BasicInformation is installed. + peer.endpoints.require(1); + expect(peer.endpoints.size).equals(2); + expect(peer.lifecycle.isSeeded).false; + expect(seededCount).equals(0); + + // Installing BasicInformation is the second half of the seed criteria and latches immediately. + peer.behaviors.require(BasicInformationClient); + controller.env.get(ClientStructureEvents).emitCluster(peer, BasicInformationClient); + + expect(peer.lifecycle.isSeeded).true; + expect(seededCount).equals(1); + }); + + it("disposes its structural-change listener once seeding latches", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + + const peerAddress = controller.peers.get("peer1")!.peerAddress!; + await MockTime.resolve(controller.peers.get("peer1")!.delete()); + + const peer = await controller.peers.forAddress(peerAddress); + + const changed = peer.lifecycle.changed; + let onCalls = 0; + let offCalls = 0; + const originalOn = changed.on.bind(changed); + const originalOff = changed.off.bind(changed); + changed.on = observer => { + onCalls++; + return originalOn(observer); + }; + changed.off = observer => { + offCalls++; + return originalOff(observer); + }; + + // Installing BasicInformation with only the root endpoint present registers the tracking listener. + peer.behaviors.require(BasicInformationClient); + controller.env.get(ClientStructureEvents).emitCluster(peer, BasicInformationClient); + expect(peer.lifecycle.isSeeded).false; + expect(onCalls).equals(1); + expect(offCalls).equals(0); + + // Adding an endpoint beyond the root satisfies the seed criteria and must remove the listener. + peer.endpoints.require(1); + expect(peer.lifecycle.isSeeded).true; + expect(offCalls).equals(1); + + // Further structural changes must not re-add or otherwise re-trigger the disposed listener. + peer.endpoints.require(2); + expect(onCalls).equals(1); + expect(offCalls).equals(1); + }); +}); + +describe("client/server lifecycle split", () => { + before(() => { + MockTime.init(); + }); + + it("uses ClientNodeLifecycle for a client node and plain NodeLifecycle for a server node", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + + const peer = controller.peers.get("peer1")!; + + expect(peer.lifecycle).instanceof(ClientNodeLifecycle); + expect(controller.lifecycle).instanceof(NodeLifecycle); + expect(controller.lifecycle instanceof ClientNodeLifecycle).false; + }); +}); diff --git a/packages/node/test/node/ServerNodeTest.ts b/packages/node/test/node/ServerNodeTest.ts index 8621d9772c..e3852b21bf 100644 --- a/packages/node/test/node/ServerNodeTest.ts +++ b/packages/node/test/node/ServerNodeTest.ts @@ -32,6 +32,7 @@ import { MockUdpSocket, NetworkSimulator, Seconds, + STANDARD_MATTER_PORT, StorageManager, StorageService, } from "@matter/general"; @@ -234,6 +235,59 @@ describe("ServerNode", () => { expect(expiration?.answers[0]?.ttl).equals(0); }); + describe("operational port", () => { + it("binds the standard Matter port for a commissionable node without an explicit port", async () => { + const node = await MockServerNode.createOnline({ + type: MockServerNode.RootEndpoint, + commissioning: { enabled: true }, + }); + + expect(node.state.network.port).equals(STANDARD_MATTER_PORT); + expect(node.state.network.operationalPort).equals(STANDARD_MATTER_PORT); + + await node.close(); + }); + + it("uses an ephemeral operational port for a controller node without an explicit port", async () => { + const node = await MockServerNode.createOnline({ + type: MockServerNode.RootEndpoint, + commissioning: { enabled: false }, + }); + + expect(node.state.network.port).equals(undefined); + expect(node.state.network.operationalPort).greaterThan(0); + expect(node.state.network.operationalPort).not.equals(STANDARD_MATTER_PORT); + + await node.close(); + }); + + it("honors an explicit port on a commissionable node", async () => { + const node = await MockServerNode.createOnline({ + type: MockServerNode.RootEndpoint, + commissioning: { enabled: true }, + network: { port: 5555 }, + }); + + expect(node.state.network.port).equals(5555); + expect(node.state.network.operationalPort).equals(5555); + + await node.close(); + }); + + it("honors an explicit port on a controller node", async () => { + const node = await MockServerNode.createOnline({ + type: MockServerNode.RootEndpoint, + commissioning: { enabled: false }, + network: { port: 5555 }, + }); + + expect(node.state.network.port).equals(5555); + expect(node.state.network.operationalPort).equals(5555); + + await node.close(); + }); + }); + it("commissions", async () => { const { node } = await commissioning.commission(); diff --git a/packages/node/test/node/SplitCommissioningTest.ts b/packages/node/test/node/SplitCommissioningTest.ts new file mode 100644 index 0000000000..72dbf6c532 --- /dev/null +++ b/packages/node/test/node/SplitCommissioningTest.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BasicInformationClient } from "#behaviors/basic-information"; +import { GeneralCommissioningServer } from "#behaviors/general-commissioning"; +import { OnOffLightDevice } from "#devices/on-off-light"; +import { ServerNode } from "#node/ServerNode.js"; +import { AbortedError, Crypto, Entropy, Environment, ImplementationError, MockCrypto, Seconds } from "@matter/general"; +import { + CertificateAuthority, + CommissioningError, + DeviceCommissioner, + DiscoveryData, + Fabric, + FabricAuthority, + FabricManager, +} from "@matter/protocol"; +import { NodeId } from "@matter/types"; +import { GeneralCommissioning } from "@matter/types/clusters/general-commissioning"; +import { MockServerNode } from "./mock-server-node.js"; +import { MockSite } from "./mock-site.js"; + +class NonOkCommissioningCompleteServer extends GeneralCommissioningServer { + override async commissioningComplete() { + return { + errorCode: GeneralCommissioning.CommissioningError.ValueOutsideRange, + debugText: "mock device rejects CommissioningComplete", + }; + } +} + +const NonOkCommissioningCompleteRoot = MockServerNode.RootEndpoint.with(NonOkCommissioningCompleteServer); + +/** + * Reconstruct the CA from the owner's exported config, then import the fabric directly, so + * {@link FabricAuthority.defaultFabric} resolves the same fabric via the shared CA root certificate. + */ +async function addControllerSharingFabric( + site: MockSite, + id: string, + index: number, + caConfig: CertificateAuthority.Configuration, + fabricConfig: Fabric.SyncConfig, +): Promise> { + const env = new Environment(id); + const crypto = MockCrypto(index); + crypto.entropic = true; + env.set(Entropy, crypto); + env.set(Crypto, crypto); + env.set(CertificateAuthority, await CertificateAuthority.create(crypto, caConfig)); + + const controller = await site.addController({ id, index, environment: env, online: false }); + + controller.env.get(FabricManager).addFabric(await Fabric.create(crypto, fabricConfig)); + + await controller.start(); + + return controller; +} + +describe("split commissioning", () => { + before(() => { + MockTime.init(); + }); + + it("controller B completes a PASE-only commissioning started by controller A", async () => { + await using site = new MockSite(); + + const a = await site.addController({ id: "controllerA", index: 1 }); + const device = await site.addDevice({ index: 2 }); + + (a.env.get(Crypto) as MockCrypto).entropic = true; + (device.env.get(Crypto) as MockCrypto).entropic = true; + + await a.start(); + + // NOTE — this is a SIMPLIFIED test shape, not the pattern to copy. Real callers trigger the completing + // controller from inside finalizeCommissioning and await it there (resolve on success, throw on failure), so + // commission() resolves only once the finalize is done — see the JSDoc on + // CommissioningClient.CommissioningOptions.finalizeCommissioning. This test instead captures the hand-off and + // completes in a second phase, purely to keep it simple: two controllers sharing one fabric identity cannot run + // the nested flow concurrently in a single process. + const { passcode, discriminator } = device.state.commissioning; + let handoff: { nodeId: NodeId; discoveryData?: DiscoveryData } | undefined; + await MockTime.resolve( + a.peers.commission({ + passcode, + discriminator, + timeout: Seconds(90), + autoSubscribe: false, + autoStateInitialize: false, + finalizeCommissioning: async (address, discoveryData) => { + handoff = { nodeId: address.nodeId, discoveryData }; + }, + }), + { macrotasks: true }, + ); + expect(handoff).not.equals(undefined); + expect(device.env.get(DeviceCommissioner).isFailsafeArmed).equals(true); + + const b = await addControllerSharingFabric( + site, + "controllerB", + 3, + a.env.get(CertificateAuthority).config, + a.env.get(FabricAuthority).fabrics[0].config, + ); + + const node = await MockTime.resolve(b.peers.completeCommissioning(handoff!.nodeId, handoff!.discoveryData), { + macrotasks: true, + }); + + expect(node.lifecycle.isCommissioned).equals(true); + expect(b.peers.commissioned.map(p => p.peerAddress?.nodeId)).contains(handoff!.nodeId); + expect(device.env.get(DeviceCommissioner).isFailsafeArmed).equals(false); + expect(device.state.commissioning.commissioned).equals(true); + + // The finalized peer must have read its structure — a blind commissioned node would hang the `seeded` wait. + expect(node.lifecycle.isSeeded).equals(true); + expect(node.stateOf(BasicInformationClient).vendorId).not.equals(undefined); + + // A second completion for an already-committed peer must reject without touching it (see the guard in Peers.completeCommissioning). + await expect( + MockTime.resolve(b.peers.completeCommissioning(handoff!.nodeId), { macrotasks: true }), + ).rejectedWith(ImplementationError); + expect(b.peers.get(b.env.get(FabricAuthority).fabrics[0].addressOf(handoff!.nodeId))).equals(node); + }); + + it("leaves no phantom peer when the node cannot be reached", async () => { + await using site = new MockSite(); + + const a = await site.addController({ id: "controllerA", index: 1 }); + const device = await site.addDevice({ index: 2 }); + + (a.env.get(Crypto) as MockCrypto).entropic = true; + (device.env.get(Crypto) as MockCrypto).entropic = true; + + await a.start(); + + // A commissions up to the hand-off so its fabric exists to share with B: + const { passcode, discriminator } = device.state.commissioning; + await MockTime.resolve( + a.peers.commission({ + passcode, + discriminator, + timeout: Seconds(90), + autoSubscribe: false, + autoStateInitialize: false, + finalizeCommissioning: async () => {}, + }), + { macrotasks: true }, + ); + + const b = await addControllerSharingFabric( + site, + "controllerB", + 3, + a.env.get(CertificateAuthority).config, + a.env.get(FabricAuthority).fabrics[0].config, + ); + + // B holds the shared fabric, so forAddress() accepts the address, but this node was never discovered and never + // answers: completeCommissioning() aborts instead of returning an errorCode, and must leave no phantom peer. + const unreachableNodeId = NodeId(0xdeadn); + + await expect( + MockTime.resolve(b.peers.completeCommissioning(unreachableNodeId), { macrotasks: true }), + ).rejectedWith(AbortedError); + + expect(b.peers.commissioned).empty; + expect(b.peers.size).equals(0); + }); + + it("throws CommissioningError and removes the peer when CommissioningComplete returns a non-Ok errorCode", async () => { + await using site = new MockSite(); + + const a = await site.addController({ id: "controllerA", index: 1 }); + const device = await site.addNode(NonOkCommissioningCompleteRoot, { device: OnOffLightDevice, index: 2 }); + + (a.env.get(Crypto) as MockCrypto).entropic = true; + (device.env.get(Crypto) as MockCrypto).entropic = true; + + await a.start(); + + const { passcode, discriminator } = device.state.commissioning; + let handoff: { nodeId: NodeId; discoveryData?: DiscoveryData } | undefined; + await MockTime.resolve( + a.peers.commission({ + passcode, + discriminator, + timeout: Seconds(90), + autoSubscribe: false, + autoStateInitialize: false, + finalizeCommissioning: async (address, discoveryData) => { + handoff = { nodeId: address.nodeId, discoveryData }; + }, + }), + { macrotasks: true }, + ); + expect(handoff).not.equals(undefined); + + const b = await addControllerSharingFabric( + site, + "controllerB", + 3, + a.env.get(CertificateAuthority).config, + a.env.get(FabricAuthority).fabrics[0].config, + ); + + await expect( + MockTime.resolve(b.peers.completeCommissioning(handoff!.nodeId, handoff!.discoveryData), { + macrotasks: true, + }), + ).rejectedWith(CommissioningError); + + expect(b.peers.commissioned).empty; + expect(b.peers.size).equals(0); + expect(device.env.get(DeviceCommissioner).isFailsafeArmed).equals(true); + expect(device.state.commissioning.commissioned).equals(false); + }); + + it("aborts and leaves no node if the fabric cannot be persisted", async () => { + await using site = new MockSite(); + + const a = await site.addController({ id: "controllerA", index: 1 }); + const device = await site.addDevice({ index: 2 }); + + (a.env.get(Crypto) as MockCrypto).entropic = true; + (device.env.get(Crypto) as MockCrypto).entropic = true; + + await a.start(); + + const { passcode, discriminator } = device.state.commissioning; + let handoff: { nodeId: NodeId; discoveryData?: DiscoveryData } | undefined; + await MockTime.resolve( + a.peers.commission({ + passcode, + discriminator, + timeout: Seconds(90), + autoSubscribe: false, + autoStateInitialize: false, + finalizeCommissioning: async (address, discoveryData) => { + handoff = { nodeId: address.nodeId, discoveryData }; + }, + }), + { macrotasks: true }, + ); + + const b = await addControllerSharingFabric( + site, + "controllerB", + 3, + a.env.get(CertificateAuthority).config, + a.env.get(FabricAuthority).fabrics[0].config, + ); + + // A node is only valid alongside its fabric: persisting the fabric happens before any node is written and + // before the device is contacted, so a persist failure aborts cleanly leaving no node and an armed failsafe. + const originalPersist = Fabric.prototype.persist; + Fabric.prototype.persist = function () { + throw new Error("simulated persistence failure"); + }; + try { + await expect( + MockTime.resolve(b.peers.completeCommissioning(handoff!.nodeId, handoff!.discoveryData), { + macrotasks: true, + }), + ).rejectedWith("simulated persistence failure"); + } finally { + Fabric.prototype.persist = originalPersist; + } + + expect(b.peers.commissioned).empty; + expect(b.peers.size).equals(0); + expect(device.env.get(DeviceCommissioner).isFailsafeArmed).equals(true); + expect(device.state.commissioning.commissioned).equals(false); + }); + + it("rejects with ImplementationError when the controller does not hold the shared fabric", async () => { + await using site = new MockSite(); + + const controller = await site.addController({ id: "controllerNoFabric", index: 1 }); + await controller.start(); + + await expect(controller.peers.completeCommissioning(NodeId(0xdeadn))).rejectedWith(ImplementationError); + expect(controller.peers.size).equals(0); + }); +}); diff --git a/packages/node/test/node/client/PeersAutoStartTest.ts b/packages/node/test/node/client/PeersAutoStartTest.ts new file mode 100644 index 0000000000..7c0ce3ed97 --- /dev/null +++ b/packages/node/test/node/client/PeersAutoStartTest.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Crypto, Minutes, MockCrypto, Seconds, Time, Timestamp } from "@matter/general"; +import { commission } from "../icd-helpers.js"; +import { MockSite } from "../mock-site.js"; +import { subscribedPeer } from "../node-helpers.js"; + +describe("Peers auto-start on owner online", () => { + before(() => MockTime.init()); + + it("auto-starts commissioned peers when the owner goes online (default)", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + const peer1 = await subscribedPeer(controller, "peer1"); + expect(peer1.lifecycle.isOnline).true; + + await MockTime.resolve(controller.stop()); + expect(peer1.lifecycle.isOnline).false; + + (controller.env.get(Crypto) as MockCrypto).entropic = true; + const online = new Promise(resolve => peer1.lifecycle.online.once(() => resolve())); + await controller.start(); + await MockTime.resolve(online, { macrotasks: true }); + + expect(peer1.lifecycle.isOnline).true; + }); + + it("does not auto-start commissioned peers when autoStartCommissionedPeers is false", async () => { + await using site = new MockSite(); + const controller = await site.addController({ network: { autoStartCommissionedPeers: false } }); + const device = await site.addDevice(); + await commission(controller, device); + const peer1 = await subscribedPeer(controller, "peer1"); + expect(peer1.lifecycle.isOnline).true; + + // Discover an uncommissioned peer whose discovery record is already expired. After the owner restarts with + // auto-start disabled, the expiration cull must still delete it — proving #nodeOnline runs #manageExpiration + // even though it skips the peer-start loop. + const device2 = await site.addDevice({ index: 3 }); + const controllerCrypto = controller.env.get(Crypto) as MockCrypto; + const deviceCrypto = device2.env.get(Crypto) as MockCrypto; + controllerCrypto.entropic = deviceCrypto.entropic = true; + const { discriminator } = device2.state.commissioning; + const discovered = await MockTime.resolve( + controller.peers.discover({ longDiscriminator: discriminator, timeout: Seconds(90) }), + { macrotasks: true }, + ); + deviceCrypto.entropic = false; + const peer2 = discovered[0]; + expect(peer2.state.commissioning.peerAddress).undefined; + await peer2.set({ commissioning: { discoveredAt: Timestamp(Time.nowMs - Minutes(20)) } }); + + await MockTime.resolve(controller.stop()); + expect(peer1.lifecycle.isOnline).false; + + controllerCrypto.entropic = true; + let cameOnline = false; + peer1.lifecycle.online.once(() => { + cameOnline = true; + }); + await MockTime.resolve(controller.start(), { macrotasks: true }); + + // Drive the event loop long enough that a would-be auto-start reconnection completes. With auto-start + // enabled the commissioned peer comes back online within this window; with it disabled it must stay offline. + await MockTime.resolve(Time.sleep("settle", Minutes(3)), { macrotasks: true }); + expect(cameOnline).false; + expect(peer1.lifecycle.isOnline).false; + + // Expiration management still runs, so the expired uncommissioned peer is culled. + expect(controller.peers.get(peer2.id)).undefined; + }); +}); diff --git a/packages/node/test/node/client/PeersCommissionedTest.ts b/packages/node/test/node/client/PeersCommissionedTest.ts new file mode 100644 index 0000000000..407cc782c2 --- /dev/null +++ b/packages/node/test/node/client/PeersCommissionedTest.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Seconds } from "@matter/general"; +import { NodeId } from "@matter/types"; +import { MockSite } from "../mock-site.js"; + +describe("Peers#commissioned", () => { + before(() => { + MockTime.init(); + }); + + it("returns only commissioned client peers, excluding commissionable discoveries and group nodes", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + + const commissionedPeer = controller.peers.get("peer1")!; + expect(commissionedPeer).not.undefined; + expect(commissionedPeer.lifecycle.isCommissioned).true; + + const uncommissionedDevice = await site.addDevice(); + const discovered = await MockTime.resolve( + controller.peers.discover({ + longDiscriminator: uncommissionedDevice.state.commissioning.discriminator, + timeout: Seconds(30), + }), + { macrotasks: true }, + ); + const commissionableCandidate = discovered[0]; + expect(commissionableCandidate).not.undefined; + expect(commissionableCandidate.lifecycle.isCommissioned).false; + expect([...controller.peers]).contains(commissionableCandidate); + + const { fabricIndex } = commissionedPeer.peerAddress!; + const group = await controller.peers.forAddress({ fabricIndex, nodeId: NodeId.fromGroupId(1) }); + expect(group.nodeType).equals("group"); + expect([...controller.peers]).contains(group); + + // A non-group operational peer established via forAddress (peerAddress set, no commissioning flow) is a + // commissioned peer and must be included. + const operationalPeer = await controller.peers.forAddress({ fabricIndex, nodeId: NodeId(0x99n) }); + expect(operationalPeer.nodeType).equals("client"); + expect(operationalPeer.lifecycle.isCommissioned).true; + + expect(new Set(controller.peers.commissioned)).deep.equals(new Set([commissionedPeer, operationalPeer])); + }); +}); diff --git a/packages/node/test/node/client/PeersInstrumentBasicInformationTest.ts b/packages/node/test/node/client/PeersInstrumentBasicInformationTest.ts new file mode 100644 index 0000000000..40f2baebfe --- /dev/null +++ b/packages/node/test/node/client/PeersInstrumentBasicInformationTest.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BasicInformationClient } from "#behaviors/basic-information"; +import { ClientStructureEvents } from "#node/client/ClientStructureEvents.js"; +import { SessionManager } from "@matter/protocol"; +import { MockSite } from "../mock-site.js"; +import { subscribedPeer } from "../node-helpers.js"; + +describe("Peers#instrumentBasicInformation", () => { + before(() => { + MockTime.init(); + }); + + it("registers device-event handlers once even when clusterInstalled re-emits for the same node", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + + const peer1 = await subscribedPeer(controller, "peer1"); + const sessionManager = controller.env.get(SessionManager); + + let startUpSessionHandlingCalls = 0; + const original = sessionManager.handlePeerShutdown.bind(sessionManager); + sessionManager.handlePeerShutdown = (...args: Parameters) => { + startUpSessionHandlingCalls++; + return original(...args); + }; + + try { + // Structure rebuilds (endpoint-install, cluster-add, peer structure rebuild) re-emit clusterInstalled + // for an already-installed cluster on the same node — cf. #instrumentBasicInformation. + const structureEvents = controller.env.get(ClientStructureEvents); + structureEvents.emitCluster(peer1, BasicInformationClient); + structureEvents.emitCluster(peer1, BasicInformationClient); + + // startUp preserves the live session (asOf = its own createdAt), so unlike shutDown/leave the guards in + // #onStartUp do not self-invalidate after the first duplicate call, letting the accumulation surface. + // Observable#emit chains async observers sequentially, awaiting each before invoking the next, so the + // returned promise must be awaited to observe all of them. + await MockTime.resolve( + peer1.act(agent => + peer1.eventsOf(BasicInformationClient).startUp?.emit({ softwareVersion: 0 }, agent.context), + ), + ); + + expect(startUpSessionHandlingCalls).equals(1); + } finally { + sessionManager.handlePeerShutdown = original; + } + }); +}); diff --git a/packages/node/test/node/client/PeersInstrumentBridgedConfigurationVersionTest.ts b/packages/node/test/node/client/PeersInstrumentBridgedConfigurationVersionTest.ts new file mode 100644 index 0000000000..08c5a48c05 --- /dev/null +++ b/packages/node/test/node/client/PeersInstrumentBridgedConfigurationVersionTest.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BridgedDeviceBasicInformationClient } from "#behaviors/bridged-device-basic-information"; +import { ClientStructureEvents } from "#node/client/ClientStructureEvents.js"; +import { MockSite } from "../mock-site.js"; +import { subscribedPeer } from "../node-helpers.js"; + +describe("Peers#instrumentBridgedConfigurationVersion", () => { + before(() => { + MockTime.init(); + }); + + it("registers the ConfigurationVersion handler once even when clusterInstalled re-emits for the same endpoint", async () => { + await using site = new MockSite(); + const { controller } = await site.addCommissionedPair(); + + const peer1 = await subscribedPeer(controller, "peer1"); + const ep1 = peer1.parts.get("ep1")!; + ep1.behaviors.require(BridgedDeviceBasicInformationClient); + + const structureEvents = controller.env.get(ClientStructureEvents); + + // Structure rebuilds (endpoint-install, cluster-add, peer structure rebuild) re-emit clusterInstalled for + // an already-installed cluster on the same endpoint — cf. #instrumentBridgedConfigurationVersion. + structureEvents.emitCluster(ep1, BridgedDeviceBasicInformationClient); + structureEvents.emitCluster(ep1, BridgedDeviceBasicInformationClient); + + let configurationVersionChangedCalls = 0; + ep1.lifecycle.configurationVersionChanged.on(() => { + configurationVersionChangedCalls++; + }); + + await MockTime.resolve( + ep1.act(agent => + ep1 + .eventsOf(BridgedDeviceBasicInformationClient) + .configurationVersion$Changed?.emit(2, 1, agent.context), + ), + ); + + expect(configurationVersionChangedCalls).equals(1); + }); +}); diff --git a/packages/nodejs-shell/src/MatterNode.ts b/packages/nodejs-shell/src/MatterNode.ts index 6657f27442..b72a7fd45e 100644 --- a/packages/nodejs-shell/src/MatterNode.ts +++ b/packages/nodejs-shell/src/MatterNode.ts @@ -6,86 +6,69 @@ // Include this first to auto-register Crypto, Network and Time Node.js implementations import { - Diagnostic, + Duration, Environment, Filesystem, + ImplementationError, + InternalError, Logger, ObserverGroup, + Seconds, StorageContext, StorageManager, StorageService, } from "@matter/general"; -import { DclBehavior, ServerNode, SoftwareUpdateManager } from "@matter/node"; -import { NodeId } from "@matter/types"; -import { CommissioningController } from "@project-chip/matter.js"; import { - CommissioningControllerNodeOptions, + ClientNode, + ControllerBehavior, + DclBehavior, Endpoint, - NodeStateInformation, - PairedNode, -} from "@project-chip/matter.js/device"; + NetworkClient, + ServerNode, + SoftwareUpdateManager, +} from "@matter/node"; +import { OtaProviderEndpoint } from "@matter/node/endpoints/ota-provider"; +import { Ble, Fabric, FabricAuthority } from "@matter/protocol"; +import { NodeId } from "@matter/types"; import { join } from "node:path"; +import { installDiagnosticLogging } from "./util/diagnosticLogging.js"; + +const logger = Logger.get("Node"); + +const ADMIN_FABRIC_LABEL = "matter.js Shell"; /** - * The shell's default per-node diagnostic callbacks — state-information, attribute-change and event logging. Applied by - * default to every {@link MatterNode.connectAndGetNodes} connection so any command that reaches a node (not just - * `nodes connect`) gets the same logging regardless of which command touched the node first. + * Options for {@link MatterNode.connectAndGetNodes}, expressed in the legacy vocabulary and mapped onto + * {@link NetworkClient} state per docs/MIGRATION_CONTROLLER_018.md. */ -export function createDiagnosticCallbacks(): Partial { - return { - attributeChangedCallback: (peerNodeId, { path: { nodeId, clusterId, endpointId, attributeName }, value }) => - console.log( - `attributeChangedCallback ${peerNodeId}: Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Diagnostic.json( - value, - )}`, - ), - eventTriggeredCallback: (peerNodeId, { path: { nodeId, clusterId, endpointId, eventName }, events }) => - console.log( - `eventTriggeredCallback ${peerNodeId}: Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Diagnostic.json( - events, - )}`, - ), - stateInformationCallback: (peerNodeId, info) => { - switch (info) { - case NodeStateInformation.Connected: - console.log(`stateInformationCallback Node ${peerNodeId} connected`); - break; - case NodeStateInformation.Disconnected: - console.log(`stateInformationCallback Node ${peerNodeId} disconnected`); - break; - case NodeStateInformation.Reconnecting: - console.log(`stateInformationCallback Node ${peerNodeId} reconnecting`); - break; - case NodeStateInformation.WaitingForDeviceDiscovery: - console.log( - `stateInformationCallback Node ${peerNodeId} waiting that device gets discovered again`, - ); - break; - case NodeStateInformation.StructureChanged: - console.log(`stateInformationCallback Node ${peerNodeId} structure changed`); - break; - case NodeStateInformation.Decommissioned: - console.log(`stateInformationCallback Node ${peerNodeId} decommissioned`); - break; - } - }, - }; +export interface ConnectClientNodeOptions { + /** When `false`, the node is not connected (left offline); it is never disabled. */ + autoConnect?: boolean; + /** When `true`, opts into a subscription on connect; otherwise the node connects without subscribing. */ + autoSubscribe?: boolean; + /** Maps to {@link NetworkClient.State.defaultSubscription}.minIntervalFloor. */ + subscribeMinIntervalFloorSeconds?: number; + /** Maps to {@link NetworkClient.State.defaultSubscription}.maxIntervalCeiling. */ + subscribeMaxIntervalCeilingSeconds?: number; } -const logger = Logger.get("Node"); - export class MatterNode { #storageLocation?: string; #storageManager?: StorageManager; #storageContext?: StorageContext; readonly #environment: Environment; - commissioningController?: CommissioningController; + #nodeEnvironment?: Environment; + #node?: ServerNode; + #nodePromise?: Promise; + #fabric?: Fabric; #started = false; + #startPromise?: Promise; readonly #nodeNum: number; readonly #netInterface?: string; #dclFetchTestCertificates = false; #allowTestOtaImages = false; #transportPreference?: "tcp" | "udp"; + #bleEnabled = false; #observers?: ObserverGroup; constructor(nodeNum: number, netInterface?: string) { @@ -104,193 +87,317 @@ export class MatterNode { } get node(): ServerNode { - if (this.commissioningController === undefined) { - throw new Error("CommissioningController not initialized. Start first"); + if (this.#node === undefined) { + throw new ImplementationError("Controller node not initialized. Call initialize() first."); } - return this.commissioningController.node; + return this.#node; + } + + /** + * The OTA provider endpoint on the controller node. The cast is unavoidable: `endpoints.for(id)` resolves by + * runtime id lookup, so it cannot statically know which endpoint type lives at "ota-provider". + */ + get otaProviderEndpoint(): Endpoint { + return this.node.endpoints.for("ota-provider") as Endpoint; } async otaService() { + await this.start(); const service = await this.node.act(agent => agent.get(DclBehavior).otaUpdateService); await service.construction; return service; } async certificateService() { + await this.start(); const service = await this.node.act(agent => agent.get(DclBehavior).certificateService); await service.construction; return service; } async vendorInfoService() { + await this.start(); const service = await this.node.act(agent => agent.get(DclBehavior).vendorInfoService); await service.construction; return service; } async initialize(resetStorage: boolean) { - /** - * Initialize the storage system. - * - * The Matter server then also uses the storage manager, so this code block in general is required, - * but you can choose a different storage backend as long as it implements the required API. - */ - - if (this.#environment) { - if (this.#netInterface !== undefined) { - this.#environment.vars.set("mdns.networkinterface", this.#netInterface); - } + if (this.#netInterface !== undefined) { + this.#environment.vars.set("mdns.networkinterface", this.#netInterface); + } - const id = `shell-${this.#nodeNum.toString()}`; + const id = `shell-${this.#nodeNum.toString()}`; + + // Scope the controller node's services (storage, mDNS) under its id, mirroring the legacy controller wrapper. + const nodeEnvironment = new Environment(id, this.#environment); + this.#nodeEnvironment = nodeEnvironment; - // Open storage up front so persisted settings can flow into the CommissioningController constructor. - this.#storageManager = await this.#environment.get(StorageService).open(id); - this.#storageContext = this.#storageManager.createContext("Node"); + // Open storage up front so persisted settings are available before the controller node is built. + this.#storageManager = await nodeEnvironment.get(StorageService).open(id); + this.#storageContext = this.#storageManager.createContext("Node"); - this.#dclFetchTestCertificates = await this.#storageContext.get("DclFetchTestCertificates", false); - this.#allowTestOtaImages = await this.#storageContext.get("AllowTestOtaImages", false); - const storedPref = await this.#storageContext.get("TransportPreference", ""); - this.#transportPreference = storedPref === "tcp" || storedPref === "udp" ? storedPref : undefined; + this.#dclFetchTestCertificates = await this.#storageContext.get("DclFetchTestCertificates", false); + this.#allowTestOtaImages = await this.#storageContext.get("AllowTestOtaImages", false); + const storedPref = await this.#storageContext.get("TransportPreference", ""); + this.#transportPreference = storedPref === "tcp" || storedPref === "udp" ? storedPref : undefined; - // Build up the "Not-so-legacy" Controller - this.commissioningController = new CommissioningController({ - environment: { - environment: this.#environment, - id, + this.#bleEnabled = (nodeEnvironment.maybeGet(Ble) ?? Environment.default.maybeGet(Ble)) !== undefined; + + // storageLocation only reads the filesystem path; it neither creates nor onlines the node and is consumed at + // boot (shell history), so it stays out of the lazy #ensureNode() path. + if (nodeEnvironment.has(Filesystem)) { + this.#storageLocation = join(nodeEnvironment.get(Filesystem).path, id); + } + + // Factory reset needs the node to clear its stores; accept eager creation only in this rare, explicit case. + // The node is created but not started, so it does not go operationally online here. + if (resetStorage) { + await (await this.#ensureNode()).erase(); + } + } + + /** + * Lazily creates the controller {@link ServerNode} on first real use. Creation runs the controller behaviors + * (binding the mDNS socket), which is why boot defers it until {@link start}. + * + * Concurrent callers (e.g. two websocket requests) share one creation via {@link #nodePromise}, so the node is + * never built twice. A failed creation clears the cached promise so a subsequent call can retry. + */ + async #ensureNode(): Promise { + if (this.#node !== undefined) { + return this.#node; + } + if (this.#nodePromise !== undefined) { + return this.#nodePromise; + } + if (this.#nodeEnvironment === undefined) { + throw new ImplementationError("Controller node accessed before initialize()"); + } + + this.#nodePromise = (async () => { + const id = `shell-${this.#nodeNum.toString()}`; + const node = await ServerNode.create(ServerNode.RootEndpoint.with(ControllerBehavior), { + environment: this.#nodeEnvironment, + id, + network: { + ble: false, + tcp: true, + transportPreference: this.#transportPreference, + // The shell connects peers strictly on demand, so opt out of the online-time bulk connect. + autoStartCommissionedPeers: false, }, - autoConnect: false, - adminFabricLabel: "matter.js Shell", - enableOtaProvider: true, - tcp: true, - transportPreference: this.#transportPreference, basicInformation: { - productName: "matter.js Shell", + productName: ADMIN_FABRIC_LABEL, + }, + controller: { + adminFabricLabel: ADMIN_FABRIC_LABEL, + ble: this.#bleEnabled, + }, + commissioning: { + enabled: false, // The controller node is never commissionable itself. + }, + subscriptions: { + persistenceEnabled: false, // Subscription persistence is a device feature, not a controller one. }, }); - const env = this.commissioningController.env; - if (env.has(Filesystem)) { - this.#storageLocation = join(env.get(Filesystem).path, id); - } + // Pulls in SoftwareUpdateManager (and thereby DclBehavior on the root node) for OTA provider support. + await node.add(new Endpoint(OtaProviderEndpoint, { id: "ota-provider" })); - if (resetStorage) { - await this.commissioningController.node.erase(); - } - } else { - console.log( - "Legacy support was removed in Matter.js 0.13. Please downgrade or migrate the storage manually", - ); - process.exit(1); + this.#node = node; + return node; + })(); + + try { + return await this.#nodePromise; + } catch (e) { + this.#nodePromise = undefined; + throw e; } } get Store() { if (!this.#storageContext) { - throw new Error("Storage uninitialized"); + throw new ImplementationError("Storage uninitialized"); } return this.#storageContext; } async close() { try { - await this.commissioningController?.close(); + await this.#node?.close(); } finally { this.#observers?.close(); await this.#storageManager?.close(); } } + /** Concurrent callers share one in-flight run via {@link #startPromise} so the setup below executes exactly once. */ async start() { if (this.#started) { return; } - logger.info(`matter.js shell controller started for node ${this.#nodeNum}`); + if (this.#startPromise !== undefined) { + return this.#startPromise; + } + + this.#startPromise = (async () => { + logger.info(`matter.js shell controller started for node ${this.#nodeNum}`); + + const node = await this.#ensureNode(); - if (this.commissioningController !== undefined) { - await this.commissioningController.start(); + // Reuse the existing controller fabric (matched by CA) or create one, rotating the NOC once per runtime. + const fabricAuthority = await node.env.load(FabricAuthority); + this.#fabric = await fabricAuthority.defaultFabric({ adminFabricLabel: ADMIN_FABRIC_LABEL }); - await this.commissioningController.node.setStateOf(DclBehavior, { + await node.start(); + + // The shell subscribes on demand (the `subscribe` command), never persistently. Clear any autoSubscribe + // carried over from a prior session or pre-migration commissioning once here, so a plain connect never + // resumes a subscription; the connect path then leaves autoSubscribe untouched (see below). + for (const peer of node.peers.commissioned) { + if (peer.stateOf(NetworkClient).autoSubscribe) { + await peer.setStateOf(NetworkClient, { autoSubscribe: false }); + } + } + + await node.setStateOf(DclBehavior, { fetchTestCertificates: this.#dclFetchTestCertificates, }); - await this.commissioningController.otaProvider.setStateOf(SoftwareUpdateManager, { + await this.otaProviderEndpoint.setStateOf(SoftwareUpdateManager, { allowTestOtaImages: this.#allowTestOtaImages, }); if (await this.Store.has("ControllerFabricLabel")) { - await this.commissioningController.updateFabricLabel( - await this.Store.get("ControllerFabricLabel", "matter.js Shell"), - ); + await this.#fabric.setLabel(await this.Store.get("ControllerFabricLabel", ADMIN_FABRIC_LABEL)); } - } else { - throw new Error("No controller initialized"); - } - this.#observers = this.#observers ?? new ObserverGroup(this.#environment.runtime); - const updateManagerEvents = this.commissioningController.otaProvider.eventsOf(SoftwareUpdateManager); - this.#observers.on(updateManagerEvents.updateAvailable, (peer, details) => { - logger.info(`Update available for peer`, peer, `:`, details); - }); - this.#observers.on(updateManagerEvents.updateDone, peer => { - logger.info(`Update done for peer`, peer); - }); - this.#observers.on(updateManagerEvents.updateFailed, peer => { - logger.info(`Update failed for peer`, peer); - }); - - this.#started = true; + this.#observers = this.#observers ?? new ObserverGroup(this.#environment.runtime); + const updateManagerEvents = this.otaProviderEndpoint.eventsOf(SoftwareUpdateManager); + this.#observers.on(updateManagerEvents.updateAvailable, (peer, details) => { + logger.info(`Update available for peer`, peer, `:`, details); + }); + this.#observers.on(updateManagerEvents.updateDone, peer => { + logger.info(`Update done for peer`, peer); + }); + this.#observers.on(updateManagerEvents.updateFailed, peer => { + logger.info(`Update failed for peer`, peer); + }); + + installDiagnosticLogging(node, this.#observers); + + this.#started = true; + })(); + + try { + await this.#startPromise; + } finally { + this.#startPromise = undefined; + } } - async connectAndGetNodes(nodeIdStr?: string, connectOptions?: CommissioningControllerNodeOptions) { + /** + * Returns the {@link ClientNode}s for the commissioned peers, connecting them (unless `autoConnect: false`). + * Nodes connect asynchronously; use {@link awaitSeeded} before relying on the endpoint structure. + */ + async connectAndGetNodes(nodeIdStr?: string, connectOptions?: ConnectClientNodeOptions): Promise { await this.start(); - const nodeId = nodeIdStr !== undefined ? NodeId(BigInt(nodeIdStr)) : undefined; - if (this.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } + const commissioned = this.node.peers.commissioned; - // Default the shell's diagnostic callbacks so a node reached via any command (icd, cluster-*, subscribe, …), - // not just `nodes connect`, gets the same logging. A caller-supplied option still wins. - const options = { ...createDiagnosticCallbacks(), ...connectOptions }; + // A specific node id must surface its own failure; the all-nodes path is best-effort (see below). + const singleNodeRequested = nodeIdStr !== undefined; - if (nodeId === undefined) { - return await this.commissioningController.connect(options); + let nodes: ClientNode[]; + if (nodeIdStr !== undefined) { + const nodeId = NodeId(BigInt(nodeIdStr)); + const node = commissioned.find(peer => peer.peerAddress?.nodeId === nodeId); + if (node === undefined) { + throw new ImplementationError(`Node ${nodeId} is not commissioned!`); + } + nodes = [node]; + } else { + nodes = commissioned; } - const node = await this.commissioningController.connectNode(nodeId, { - ...options /*autoConnect: false*/, - }); - if (!node.initialized) { - await node.events.initialized; + for (const node of nodes) { + try { + await this.#applyClientNodeNetworkOptions(node, connectOptions); + if (connectOptions?.autoConnect === false) { + continue; + } + // A disabled peer (persisted from a prior run) rejects start(); enable() clears isDisabled and + // starts it. Safe because autoStartCommissionedPeers is off, so the now-enabled peer won't + // auto-connect on next boot. + if (node.stateOf(NetworkClient).isDisabled) { + await node.enable(); + } else { + await node.start(); + } + } catch (e) { + if (singleNodeRequested) { + throw e; + } + // Best-effort across all commissioned peers: one unreachable peer must not abort the rest. + logger.warn(`Node ${node.peerAddress?.nodeId} failed to connect:`, e); + } } - return [node]; + + return nodes; } - get controller() { - if (this.commissioningController === undefined) { - throw new Error("CommissioningController not initialized. Start first"); + async #applyClientNodeNetworkOptions(node: ClientNode, options?: ConnectClientNodeOptions) { + // connect ≠ subscribe: a plain connect leaves autoSubscribe untouched (it was normalized to false once at + // startup), so a subscription established this session via the `subscribe` command survives subsequent + // commands. Only an explicit opt-in flips it here. + if (options?.autoSubscribe !== undefined) { + await node.setStateOf(NetworkClient, { autoSubscribe: options.autoSubscribe }); + } + + const { subscribeMinIntervalFloorSeconds, subscribeMaxIntervalCeilingSeconds } = options ?? {}; + if (subscribeMinIntervalFloorSeconds !== undefined || subscribeMaxIntervalCeilingSeconds !== undefined) { + const defaultSubscription: { minIntervalFloor?: Duration; maxIntervalCeiling?: Duration } = {}; + if (subscribeMinIntervalFloorSeconds !== undefined) { + defaultSubscription.minIntervalFloor = Seconds(subscribeMinIntervalFloorSeconds); + } + if (subscribeMaxIntervalCeilingSeconds !== undefined) { + defaultSubscription.maxIntervalCeiling = Seconds(subscribeMaxIntervalCeilingSeconds); + } + await node.setStateOf(NetworkClient, { defaultSubscription }); } - return this.commissioningController; } + /** + * Iterates the endpoints of each {@link ClientNode}, including the root endpoint (number 0); pass + * {@link endpointIds} to restrict iteration. + */ async iterateNodeDevices( - nodes: PairedNode[], - callback: (device: Endpoint, node: PairedNode) => Promise, - endpointId?: number, - ) { + nodes: ClientNode[], + callback: (device: Endpoint, node: ClientNode) => Promise, + endpointIds?: number[], + ): Promise { for (const node of nodes) { - let devices = node.getDevices(); - if (endpointId !== undefined) { - devices = devices.filter(device => device.number === endpointId); - } - - for (const device of devices) { + for (const device of node.endpoints) { + if (endpointIds !== undefined && !endpointIds.includes(device.number)) { + continue; + } await callback(device, node); } } } - updateFabricLabel(label: string) { - return this.commissioningController?.updateFabricLabel(label); + /** + * Updates the controller's admin fabric label on the local fabric. Propagation to already-connected peers is not + * reproduced and moves to the node-management layer per docs/MIGRATION_CONTROLLER_018.md. + * MIGRATION-GAP: updatefabriclabel-note. + */ + async updateFabricLabel(label: string) { + await this.start(); + if (this.#fabric === undefined) { + throw new InternalError("Controller fabric not initialized"); + } + await this.#fabric.setLabel(label); } } diff --git a/packages/nodejs-shell/src/app.ts b/packages/nodejs-shell/src/app.ts index 70ab215c4d..7b2f6f2b96 100644 --- a/packages/nodejs-shell/src/app.ts +++ b/packages/nodejs-shell/src/app.ts @@ -134,6 +134,15 @@ async function main() { webAddress, } = argv; + // Install BLE before the controller node initializes: the Ble service is registered reactively + // when `ble.enable` flips, and the node reads its availability during initialize(). + if (bleEnable) { + Environment.default.vars.set("ble.enable", true); + } + if (bleHciId !== undefined) { + Environment.default.vars.set("ble.hci.id", bleHciId); + } + theNode = new MatterNode(nodeNum, netInterface); await theNode.initialize(factoryReset); @@ -159,14 +168,6 @@ async function main() { theShell = new Shell(theNode, nodeNum, PROMPT, process.stdin, process.stdout); } - if (bleEnable) { - Environment.default.vars.set("ble.enable", true); - } - - if (bleHciId !== undefined) { - Environment.default.vars.set("ble.hci.id", bleHciId); - } - console.log(`Started Node #${nodeNum} (Type: ${nodeType}) ${bleEnable ? "with" : "without"} BLE`); if (!webSocketInterface) { theShell.start(theNode.storageLocation); diff --git a/packages/nodejs-shell/src/shell/cmd_cluster-attributes.ts b/packages/nodejs-shell/src/shell/cmd_cluster-attributes.ts index 62bdc5717b..918c32c13f 100644 --- a/packages/nodejs-shell/src/shell/cmd_cluster-attributes.ts +++ b/packages/nodejs-shell/src/shell/cmd_cluster-attributes.ts @@ -4,12 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Diagnostic } from "@matter/general"; +import { Diagnostic, ImplementationError } from "@matter/general"; import { AttributeModel, ClusterModel, Matter } from "@matter/model"; -import { AttributeId, ClusterId, EndpointNumber, ValidationError } from "@matter/types"; -import { SupportedAttributeClient } from "@project-chip/matter.js/cluster"; +import { AttributeId, ClusterId, ClusterLookup, EndpointNumber, ValidationError } from "@matter/types"; import type { Argv } from "yargs"; import { MatterNode } from "../MatterNode.js"; +import { + elementKnownUnsupported, + readAttributesRemote, + resolveClusterEndpoint, + ResolvedClusterEndpoint, +} from "../util/ClusterEndpoint.js"; import { convertJsonDataWithModel } from "../util/Json.js"; function generateAllAttributeHandlersForCluster(yargs: Argv, theNode: MatterNode) { @@ -55,31 +60,32 @@ function generateAllAttributeHandlersForCluster(yargs: Argv, theNode: MatterNode const node = (await theNode.connectAndGetNodes(nodeId))[0]; try { - const interactionClient = await node.getInteractionClient(); - const result = await interactionClient.getMultipleAttributes({ - attributes: [ + const values = await readAttributesRemote( + node, + [ { endpointId: EndpointNumber(endpointId), clusterId: ClusterId(clusterId), attributeId: attributeId !== undefined ? AttributeId(attributeId) : undefined, }, ], - isFabricFiltered: fabricFiltered, - }); + fabricFiltered, + ); console.log( - `Attribute values for cluster ${node.nodeId.toString()}/${endpointId}/${clusterId}/${attributeId}:`, + `Attribute values for cluster ${node.peerAddress?.nodeId}/${endpointId}/${clusterId}/${attributeId}:`, ); for (const { - path: { attributeId, attributeName }, + path: { attributeId: id }, value, - } of result) { + } of values) { + const attributeName = ClusterLookup.attributeName(ClusterId(clusterId), id, node.matter); console.log( - ` ${Diagnostic.hex(attributeId)}${attributeName !== undefined ? ` (${attributeName})` : ""}: ${Diagnostic.json(value)}`, + ` ${Diagnostic.hex(id)}${attributeName !== undefined ? ` (${attributeName})` : ""}: ${Diagnostic.json(value)}`, ); } } catch (error) { console.log( - `ERROR: Could not get attribute ${node.nodeId.toString()}/${endpointId}/${clusterId}/${attributeId}: ${error}`, + `ERROR: Could not get attribute ${node.peerAddress?.nodeId}/${endpointId}/${clusterId}/${attributeId}: ${error}`, ); } }, @@ -132,35 +138,31 @@ function generateClusterAttributeHandlers(yargs: Argv, cluster: ClusterModel, th }); }, async argv => { - const clusterId = cluster.id; const { nodeId, endpointId, remote, fabricFiltered } = argv; - const requestRemote = remote ? true : undefined; - const node = (await theNode.connectAndGetNodes(nodeId))[0]; - - const clusterClient = node - .getDeviceById(endpointId) - ?.getClusterClientById(ClusterId(clusterId!)); - if (clusterClient === undefined) { - console.log( - `ERROR: Cluster ${node.nodeId.toString()}/${endpointId}/${clusterId} not found.`, - ); + const resolved = await resolveClusterEndpoint(theNode, nodeId, endpointId, clusterId!); + if (resolved === undefined) { return; } + const { node, endpoint, behaviorType } = resolved; + console.log( - `Attribute values for cluster ${cluster.name} (${node.nodeId.toString()}/${endpointId}/${clusterId}):`, + `Attribute values for cluster ${cluster.name} (${node.peerAddress?.nodeId}/${endpointId}/${clusterId}):`, ); for (const attribute of cluster.attributes) { const attributeName = attribute.propertyName; - const attributeClient = clusterClient.attributes[attributeName]; if ( - attributeClient === undefined || - (!remote && !(attributeClient instanceof SupportedAttributeClient)) + !remote && + elementKnownUnsupported(endpoint, behaviorType, "attributes", attributeName) ) { continue; } - console.log( - ` ${attributeName} (${attribute.id}): ${Diagnostic.json(await attributeClient.get(requestRemote, fabricFiltered))}`, - ); + try { + console.log( + ` ${attributeName} (${attribute.id}): ${Diagnostic.json(await getAttributeValue(resolved, clusterId!, attribute, remote, fabricFiltered))}`, + ); + } catch (error) { + console.log(` ${attributeName} (${attribute.id}): ERROR: ${error}`); + } } }, ); @@ -204,6 +206,35 @@ function generateClusterAttributeHandlers(yargs: Argv, cluster: ClusterModel, th return yargs; } +/** Fetch a single attribute's value: cached behavior state, or a live interaction read when `requestRemote`. */ +async function getAttributeValue( + resolved: ResolvedClusterEndpoint, + clusterId: number, + attribute: AttributeModel, + requestRemote: boolean, + fabricFiltered: boolean, +): Promise { + const { node, endpoint, behaviorType } = resolved; + if (requestRemote) { + const values = await readAttributesRemote( + node, + [ + { + endpointId: endpoint.number, + clusterId: ClusterId(clusterId), + attributeId: AttributeId(attribute.id), + }, + ], + fabricFiltered, + ); + if (values.length === 0) { + throw new ImplementationError(`No value returned for attribute ${attribute.propertyName}`); + } + return values[0].value; + } + return endpoint.stateOf(behaviorType.id)[attribute.propertyName]; +} + function generateAttributeReadHandler( yargs: Argv, clusterId: number, @@ -242,24 +273,21 @@ function generateAttributeReadHandler( }), async argv => { const { nodeId, endpointId, remote, fabricFiltered } = argv; - const requestRemote = remote ? true : undefined; - const node = (await theNode.connectAndGetNodes(nodeId))[0]; - - const clusterClient = node.getDeviceById(endpointId)?.getClusterClientById(ClusterId(clusterId)); - if (clusterClient === undefined) { - console.log(`ERROR: Cluster ${node.nodeId.toString()}/${endpointId}/${clusterId} not found.`); + const resolved = await resolveClusterEndpoint(theNode, nodeId, endpointId, clusterId); + if (resolved === undefined) { return; } - const attributeClient = clusterClient.attributes[attributeName]; - if (attributeClient === undefined || (!remote && !(attributeClient instanceof SupportedAttributeClient))) { + const { node, endpoint, behaviorType } = resolved; + + if (!remote && elementKnownUnsupported(endpoint, behaviorType, "attributes", attributeName)) { console.log( - `ERROR: Attribute ${node.nodeId.toString()}/${endpointId}/${clusterId}/${attribute.id} not supported by the device.`, + `ERROR: Attribute ${node.peerAddress?.nodeId}/${endpointId}/${clusterId}/${attribute.id} not supported by the device.`, ); return; } try { console.log( - `Attribute value for ${attributeName} ${node.nodeId.toString()}/${endpointId}/${clusterId}/${attribute.id}: ${Diagnostic.json(await attributeClient.get(requestRemote, fabricFiltered))}`, + `Attribute value for ${attributeName} ${node.peerAddress?.nodeId}/${endpointId}/${clusterId}/${attribute.id}: ${Diagnostic.json(await getAttributeValue(resolved, clusterId, attribute, remote, fabricFiltered))}`, ); } catch (error) { console.log(`ERROR: Could not get attribute ${attribute.name}: ${error}`); @@ -275,8 +303,6 @@ function generateAttributeWriteHandler( attribute: AttributeModel, theNode: MatterNode, ) { - //console.log("Generating attribute handler for ", attribute.name, attribute); - //console.log(attribute.definingModel); const attributeName = attribute.propertyName; const typeHint = `${attribute.type}${attribute.definingModel === undefined ? "" : " as JSON string"}`; return yargs.command( @@ -321,17 +347,15 @@ function generateAttributeWriteHandler( } } - const node = (await theNode.connectAndGetNodes(nodeId))[0]; - - const clusterClient = node.getDeviceById(endpointId)?.getClusterClientById(ClusterId(clusterId)); - if (clusterClient === undefined) { - console.log(`ERROR: Cluster ${node.nodeId.toString()}/${endpointId}/${clusterId} not found.`); + const resolved = await resolveClusterEndpoint(theNode, nodeId, endpointId, clusterId); + if (resolved === undefined) { return; } - const attributeClient = clusterClient.attributes[attributeName]; - if (!force && !(attributeClient instanceof SupportedAttributeClient)) { + const { node, endpoint, behaviorType } = resolved; + + if (!force && elementKnownUnsupported(endpoint, behaviorType, "attributes", attributeName)) { console.log( - `ERROR: Attribute ${node.nodeId.toString()}/${endpointId}/${clusterId}/${attribute.id} not supported by the device.`, + `ERROR: Attribute ${node.peerAddress?.nodeId}/${endpointId}/${clusterId}/${attribute.id} not supported by the device.`, ); return; } @@ -339,9 +363,9 @@ function generateAttributeWriteHandler( try { parsedValue = convertJsonDataWithModel(attribute, parsedValue); - await attributeClient.set(parsedValue); + await endpoint.setStateOf(behaviorType.id, { [attributeName]: parsedValue }); console.log( - `Attribute ${attributeName} ${node.nodeId.toString()}/${endpointId}/${clusterId}/${attribute.id} set to ${Diagnostic.json(value)}`, + `Attribute ${attributeName} ${node.peerAddress?.nodeId}/${endpointId}/${clusterId}/${attribute.id} set to ${Diagnostic.json(value)}`, ); } catch (error) { if (error instanceof ValidationError) { diff --git a/packages/nodejs-shell/src/shell/cmd_cluster-commands.ts b/packages/nodejs-shell/src/shell/cmd_cluster-commands.ts index ccb32ba459..4df0aafc5c 100644 --- a/packages/nodejs-shell/src/shell/cmd_cluster-commands.ts +++ b/packages/nodejs-shell/src/shell/cmd_cluster-commands.ts @@ -6,9 +6,10 @@ import { Diagnostic } from "@matter/general"; import { ClusterModel, CommandModel, Matter } from "@matter/model"; -import { ClusterId, ValidationError } from "@matter/types"; +import { ValidationError } from "@matter/types"; import type { Argv } from "yargs"; import { MatterNode } from "../MatterNode.js"; +import { elementKnownUnsupported, resolveClusterEndpoint } from "../util/ClusterEndpoint.js"; import { convertJsonDataWithModel } from "../util/Json.js"; function generateAllCommandHandlersForCluster(yargs: Argv, theNode: MatterNode) { @@ -48,9 +49,6 @@ function generateCommandHandler( command: CommandModel, theNode: MatterNode, ) { - //console.log("Generating command handler for ", command.name, JSON.stringify(command)); - //console.log(command.definingModel); - if (command.definingModel !== undefined) { // If command has parameters for the call return yargs.command( @@ -126,15 +124,16 @@ async function executeCommand( ) { const commandName = command.propertyName; - const node = (await theNode.connectAndGetNodes(nodeId))[0]; - - const clusterClient = node.getDeviceById(endpointId)?.getClusterClientById(ClusterId(clusterId)); - if (clusterClient === undefined) { - console.log(`ERROR: Cluster ${node.nodeId.toString()}/${endpointId}/${clusterId} not found.`); + const resolved = await resolveClusterEndpoint(theNode, nodeId, endpointId, clusterId); + if (resolved === undefined) { return; } - if (clusterClient.commands[commandName] === undefined) { - console.log(`ERROR: Command ${node.nodeId.toString()}/${endpointId}/${clusterId}/${command.id} not supported.`); + const { node, endpoint, behaviorType } = resolved; + + if (elementKnownUnsupported(endpoint, behaviorType, "commands", commandName)) { + console.log( + `ERROR: Command ${node.peerAddress?.nodeId}/${endpointId}/${clusterId}/${command.id} not supported.`, + ); return; } try { @@ -142,9 +141,9 @@ async function executeCommand( requestData = convertJsonDataWithModel(command, requestData); } - const result = await clusterClient.commands[commandName](requestData); + const result = await endpoint.commandsOf(behaviorType.id)[commandName](requestData); console.log( - `Command ${command.name} ${node.nodeId.toString()}/${endpointId}/${clusterId}/${command.id} invoked ${requestData ? `with ${Diagnostic.json(requestData)}` : ""}`, + `Command ${command.name} ${node.peerAddress?.nodeId}/${endpointId}/${clusterId}/${command.id} invoked ${requestData ? `with ${Diagnostic.json(requestData)}` : ""}`, ); if (result !== undefined) { console.log(`Result: ${Diagnostic.json(result)}`); diff --git a/packages/nodejs-shell/src/shell/cmd_cluster-events.ts b/packages/nodejs-shell/src/shell/cmd_cluster-events.ts index 3748741fb4..e7c3e73bca 100644 --- a/packages/nodejs-shell/src/shell/cmd_cluster-events.ts +++ b/packages/nodejs-shell/src/shell/cmd_cluster-events.ts @@ -6,9 +6,10 @@ import { Diagnostic } from "@matter/general"; import { ClusterModel, EventModel, Matter } from "@matter/model"; -import { ClusterId } from "@matter/types"; +import { ClusterId, EventId } from "@matter/types"; import type { Argv } from "yargs"; import { MatterNode } from "../MatterNode.js"; +import { readEventsRemote, resolveClusterEndpoint } from "../util/ClusterEndpoint.js"; function generateAllEventHandlersForCluster(yargs: Argv, theNode: MatterNode) { Matter.clusters.forEach(cluster => { @@ -47,7 +48,6 @@ function generateEventHandler( event: EventModel, theNode: MatterNode, ) { - //console.log("Generating event handler for ", event.name, JSON.stringify(event)); const eventName = event.propertyName; return yargs.command( [`${event.name.toLowerCase()} `, `0x${event.id.toString(16)}`], @@ -66,17 +66,44 @@ function generateEventHandler( }), async argv => { const { nodeId, endpointId } = argv; - const node = (await theNode.connectAndGetNodes(nodeId))[0]; - - const clusterClient = node.getDeviceById(endpointId)?.getClusterClientById(ClusterId(clusterId)); - if (clusterClient === undefined) { - console.log(`ERROR: Cluster ${node.nodeId.toString()}/${endpointId}/${clusterId} not found.`); + const resolved = await resolveClusterEndpoint(theNode, nodeId, endpointId, clusterId); + if (resolved === undefined) { return; } - const eventClient = clusterClient.events[eventName]; - console.log( - `Event value for ${eventName} ${node.nodeId.toString()}/${endpointId}/${clusterId}/${event.id}: ${Diagnostic.json(await eventClient.get())}`, - ); + const { node, endpoint } = resolved; + + try { + // No local event cache; every read is a live interaction round trip, same as the legacy EventClient. + const reports = await readEventsRemote( + node, + [{ endpointId: endpoint.number, clusterId: ClusterId(clusterId), eventId: EventId(event.id) }], + true, + ); + const events = reports.map( + ({ + number, + priority, + epochTimestamp, + systemTimestamp, + deltaEpochTimestamp, + deltaSystemTimestamp, + value, + }) => ({ + eventNumber: number, + priority, + epochTimestamp, + systemTimestamp, + deltaEpochTimestamp, + deltaSystemTimestamp, + data: value, + }), + ); + console.log( + `Event value for ${eventName} ${node.peerAddress?.nodeId}/${endpointId}/${clusterId}/${event.id}: ${Diagnostic.json(events)}`, + ); + } catch (error) { + console.log(`ERROR: Could not get event ${event.name}: ${error}`); + } }, ); } diff --git a/packages/nodejs-shell/src/shell/cmd_commission.ts b/packages/nodejs-shell/src/shell/cmd_commission.ts index 821fab68f9..86762e31f3 100644 --- a/packages/nodejs-shell/src/shell/cmd_commission.ts +++ b/packages/nodejs-shell/src/shell/cmd_commission.ts @@ -4,15 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Diagnostic, Logger, MatterError } from "@matter/general"; +import { Diagnostic, ImplementationError, Logger, Seconds } from "@matter/general"; +import { ClientNode, CommissioningClient } from "@matter/node"; +import { BasicInformationClient } from "@matter/node/behaviors/basic-information"; +import { DescriptorClient } from "@matter/node/behaviors/descriptor"; import { DiscoveryCapabilitiesSchema, ManualPairingCodeCodec, NodeId, QrCode, QrPairingCodeCodec } from "@matter/types"; -import { BasicInformation, Descriptor, GeneralCommissioning } from "@matter/types/clusters"; -import { NodeCommissioningOptions } from "@project-chip/matter.js"; +import { GeneralCommissioning } from "@matter/types/clusters"; import type { Argv } from "yargs"; -import { createDiagnosticCallbacks, MatterNode } from "../MatterNode.js"; +import { MatterNode } from "../MatterNode.js"; +import { awaitSeeded } from "../util/awaitSeeded.js"; const logger = Logger.get("Commission"); +/** Look up a commissioned peer by node id across the controller's default admin fabric. */ +function findCommissionedNode(theNode: MatterNode, nodeId: NodeId): ClientNode | undefined { + return theNode.node.peers.commissioned.find(peer => peer.peerAddress?.nodeId === nodeId); +} + export default function commands(theNode: MatterNode) { return { command: "commission", @@ -23,9 +31,53 @@ export default function commands(theNode: MatterNode) { .command("pair", "Pair with a matter device", yargs => { return ( yargs + // Options are registered before the command below so their types flow into its handler's argv. + .options({ + pairingCode: { + describe: "pairing code", + default: undefined, + type: "string", + }, + qrCode: { + describe: "QR code string (MT:...)", + default: undefined, + type: "string", + }, + qrCodeIndex: { + describe: "Index of QR code entry if multiple (1..n)", + default: 1, + type: "number", + }, + setupPinCode: { + describe: "setup pin code", + default: 20202021, + type: "number", + }, + instanceId: { + alias: "i", + describe: "instance id", + type: "string", + }, + discriminator: { + alias: "d", + description: "Long discriminator", + type: "number", + }, + shortDiscriminator: { + alias: "s", + description: "Short discriminator", + type: "number", + }, + ble: { + alias: "b", + description: "Also discover over BLE", + type: "boolean", + default: false, + }, + }) // Pair .command( - "* [node-id] [ip:[port]]", + "* [node-id] [ip] [port]", "Commission a matter device", yargs => { return yargs @@ -46,17 +98,16 @@ export default function commands(theNode: MatterNode) { }); }, async argv => { - const { - pairingCode, - qrCode, - nodeId: nodeIdStr, - ipPort, - ip, - ble = false, - instanceId, - } = argv; + const { pairingCode, qrCode, nodeId: nodeIdStr, port, ip, ble, instanceId } = argv; let { setupPinCode, discriminator, shortDiscriminator, qrCodeIndex } = argv; + if ((ip === undefined) !== (port === undefined)) { + console.log( + "Known-address commissioning requires both and ; provide both or neither (to use discovery).", + ); + return; + } + if (typeof pairingCode === "string" && pairingCode.length > 0) { const { shortDiscriminator: pairingCodeShortDiscriminator, passcode } = ManualPairingCodeCodec.decode(pairingCode); @@ -104,32 +155,6 @@ export default function commands(theNode: MatterNode) { const nodeId = nodeIdStr !== undefined ? NodeId(BigInt(nodeIdStr)) : undefined; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } - - const options = { - discovery: { - knownAddress: - ip !== undefined && ipPort !== undefined - ? { ip, port: ipPort, type: "udp" } - : undefined, - identifierData: - instanceId !== undefined - ? { instanceId } - : discriminator !== undefined - ? { longDiscriminator: discriminator } - : shortDiscriminator !== undefined - ? { shortDiscriminator } - : {}, - discoveryCapabilities: { - ble, - onIpNetwork: true, - }, - }, - passcode: setupPinCode, - ...createDiagnosticCallbacks(), - } as NodeCommissioningOptions; // Attestation policy: strict rejects errors but allows warnings/info const strictAttestation = await theNode.Store.get( @@ -137,8 +162,12 @@ export default function commands(theNode: MatterNode) { false, ); - options.commissioning = { - nodeId: nodeId !== undefined ? NodeId(nodeId) : undefined, + const commissioningOptions: CommissioningClient.CommissioningOptions = { + passcode: setupPinCode, + discriminator, + nodeId, + // The shell subscribes on demand via the `subscribe` command, not at commission. + autoSubscribe: false, regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.Outdoor, // Set to the most restrictive if relevant regulatoryCountryCode: "XX", onAttestationFailure: findings => { @@ -156,103 +185,82 @@ export default function commands(theNode: MatterNode) { }, }; - console.log(Diagnostic.json(options)); + // Keep secrets out of the debug dump (CodeQL: clear-text logging of sensitive + // info). Omit the passcode entirely — spreading then overriding still flows it + // into the logged object — and mask network credentials. + const { passcode: _passcode, wifiNetwork, ...loggable } = commissioningOptions; + console.log( + Diagnostic.json({ + ...loggable, + ...(wifiNetwork && { + wifiNetwork: { ...wifiNetwork, wifiCredentials: "" }, + }), + }), + ); await theNode.certificateService(); const wifiSsid = await theNode.Store.get("WiFiSsid", ""); const wifiCredentials = await theNode.Store.get("WiFiPassword", ""); if (wifiSsid.length > 0 && wifiCredentials.length > 0) { - options.commissioning.wifiNetwork = { wifiSsid, wifiCredentials }; + commissioningOptions.wifiNetwork = { wifiSsid, wifiCredentials }; } const threadOperationalDataset = await theNode.Store.get( "ThreadOperationalDataset", "", ); if (threadOperationalDataset.length > 0) { - options.commissioning.threadNetwork = { + commissioningOptions.threadNetwork = { networkName: await theNode.Store.get("ThreadName", ""), operationalDataset: threadOperationalDataset, }; } - const commissionedNodeId = - await theNode.commissioningController.commissionNode(options); + let node: ClientNode; + if (ip !== undefined && port !== undefined) { + // Known address: locate/create the peer node directly instead of running mDNS + // discovery. Mirrors MatterController's own known-address commissioning path. + node = await theNode.node.peers.forDescriptor({ + addresses: [{ ip, port, type: "udp" }], + }); + await node.commission(commissioningOptions); + } else { + const identifierData = + instanceId !== undefined + ? { instanceId } + : shortDiscriminator !== undefined + ? { shortDiscriminator } + : {}; + node = await theNode.node.peers.commission({ + ...identifierData, + ...commissioningOptions, + discoveryCapabilities: { ble, onIpNetwork: true }, + }); + } - console.log("Commissioned Node:", commissionedNodeId); + console.log("Commissioned Node:", node.peerAddress?.nodeId.toString()); - const node = theNode.commissioningController.getPairedNode(commissionedNodeId); - if (node === undefined) { - // Should not happen - throw new MatterError("Node not found after commissioning."); + if (!(await awaitSeeded(node))) { + return; } - // Important: This is a temporary API to proof the methods working and this will change soon and is NOT stable! - // It is provided to proof the concept - - // Example to initialize a ClusterClient and access concrete fields as API methods - const descriptor = node.getRootClusterClient(Descriptor); + // Example to read cluster state directly instead of via a ClusterClient + const descriptor = node.maybeStateOf(DescriptorClient); if (descriptor !== undefined) { - console.log(await descriptor.attributes.deviceTypeList.get()); // you can call that way - console.log(await descriptor.getServerListAttribute()); // or more convenient that way + console.log(descriptor.deviceTypeList); + console.log(descriptor.serverList); } else { console.log("No Descriptor Cluster found. This should never happen!"); } - // Example to subscribe to a field and get the value - const info = node.getRootClusterClient(BasicInformation); + const info = node.maybeStateOf(BasicInformationClient); if (info !== undefined) { - console.log(await info.getProductNameAttribute()); // This call is executed remotely - //console.log(await info.subscribeProductNameAttribute(value => console.log("productName", value), 5, 30)); - //console.log(await info.getProductNameAttribute()); // This call is resolved locally because we have subscribed to the value! + console.log(info.productName); } else { console.log("No BasicInformation Cluster found. This should never happen!"); } }, ) - .options({ - pairingCode: { - describe: "pairing code", - default: undefined, - type: "string", - }, - qrCode: { - describe: "QR code string (MT:...)", - default: undefined, - type: "string", - }, - qrCodeIndex: { - describe: "Index of QR code entry if multiple (1..n)", - default: 1, - type: "number", - }, - setupPinCode: { - describe: "setup pin code", - default: 20202021, - type: "number", - }, - instanceId: { - alias: "i", - describe: "instance id", - type: "string", - }, - discriminator: { - alias: "d", - description: "Long discriminator", - type: "number", - }, - shortDiscriminator: { - alias: "s", - description: "Short discriminator", - type: "number", - }, - ble: { - alias: "b", - description: "Also discover over BLE", - type: "boolean", - default: false, - }, - }) ); }) .command( @@ -275,8 +283,11 @@ export default function commands(theNode: MatterNode) { const { nodeId, timeout } = argv; await theNode.start(); const node = (await theNode.connectAndGetNodes(nodeId, { autoSubscribe: false }))[0]; + if (!(await awaitSeeded(node))) { + return; + } - await node.openBasicCommissioningWindow(timeout); + await node.openBasicCommissioningWindow(Seconds(timeout)); console.log(`Basic Commissioning Window for node ${nodeId} opened`); }, @@ -301,10 +312,14 @@ export default function commands(theNode: MatterNode) { await theNode.start(); const { nodeId, timeout } = argv; const node = (await theNode.connectAndGetNodes(nodeId, { autoSubscribe: false }))[0]; - const data = await node.openEnhancedCommissioningWindow(timeout); + if (!(await awaitSeeded(node))) { + return; + } + const { qrPairingCode, manualPairingCode } = await node.openEnhancedCommissioningWindow( + Seconds(timeout), + ); console.log(`Enhanced Commissioning Window for node ${nodeId} opened`); - const { qrPairingCode, manualPairingCode } = data; console.log(QrCode.get(qrPairingCode)); console.log( @@ -333,11 +348,19 @@ export default function commands(theNode: MatterNode) { }, async argv => { await theNode.start(); - const { nodeId, force } = argv; + const { nodeId: nodeIdStr, force } = argv; if (force) { - await theNode.controller.removeNode(NodeId(BigInt(nodeId)), !force); + // Force: skip the on-device decommission attempt and just drop the peer locally. + const node = findCommissionedNode(theNode, NodeId(BigInt(nodeIdStr))); + if (node === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); + } + await node.delete(); } else { - const node = (await theNode.connectAndGetNodes(nodeId, { autoSubscribe: false }))[0]; + const node = (await theNode.connectAndGetNodes(nodeIdStr, { autoSubscribe: false }))[0]; + if (!(await awaitSeeded(node))) { + return; + } await node.decommission(); } }, diff --git a/packages/nodejs-shell/src/shell/cmd_discover.ts b/packages/nodejs-shell/src/shell/cmd_discover.ts index 7587fd6def..5c34cc16f8 100644 --- a/packages/nodejs-shell/src/shell/cmd_discover.ts +++ b/packages/nodejs-shell/src/shell/cmd_discover.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Diagnostic, Seconds } from "@matter/general"; +import { ChannelType, Diagnostic, Seconds } from "@matter/general"; import { CommissionableDeviceIdentifiers } from "@matter/protocol"; import { ManualPairingCodeCodec, VendorId } from "@matter/types"; import type { Argv } from "yargs"; @@ -88,9 +88,6 @@ export default function commands(theNode: MatterNode) { } await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } const identifierData: CommissionableDeviceIdentifiers = discriminator !== undefined @@ -111,25 +108,42 @@ export default function commands(theNode: MatterNode) { )} for ${once ? "first match or " : ""}${timeoutSeconds} seconds.`, ); - const results = await theNode.commissioningController.discoverCommissionableDevices( - identifierData, - { - ble, - onIpNetwork: true, - }, - device => { - console.log(`Discovered device ${Diagnostic.json(device)}`); - if (once) { - theNode.commissioningController?.cancelCommissionableDeviceDiscovery( - identifierData, - { ble, onIpNetwork: true }, - ); - } - }, - Seconds(timeoutSeconds), - ); + // scannerFilter mirrors the discoveryCapabilities->filter mapping in CommissioningDiscovery. + const discovery = theNode.node.peers.discover({ + ...identifierData, + timeout: Seconds(timeoutSeconds), + scannerFilter: scanner => + scanner.type === ChannelType.UDP || (ble && scanner.type === ChannelType.BLE), + }); + // A re-advertising device fires `discovered` again for the same node; dedupe by its + // canonical identity so the log doesn't spam duplicate lines. + const seen = new Set(); + discovery.discovered.on(node => { + const { deviceIdentifier, addresses } = node.state.commissioning; + // Prefer the stable device identifier; the address fallback is sorted so re-advertisement + // in a different order does not read as a new device (ServerAddress carries no volatile fields). + const id = + deviceIdentifier || + (addresses ?? []) + .map(a => JSON.stringify(a)) + .sort() + .join("|"); + if (seen.has(id)) { + return; + } + seen.add(id); + console.log(`Discovered device ${Diagnostic.json(node.state.commissioning)}`); + if (once) { + discovery.stop(); + } + }); + + const results = await discovery; - console.log(`Discovered ${results.length} devices`, results); + console.log( + `Discovered ${results.length} devices`, + results.map(node => node.state.commissioning), + ); }, ), handler: async (argv: any) => { diff --git a/packages/nodejs-shell/src/shell/cmd_icd.ts b/packages/nodejs-shell/src/shell/cmd_icd.ts index bcebc6341e..1bd13bac1e 100644 --- a/packages/nodejs-shell/src/shell/cmd_icd.ts +++ b/packages/nodejs-shell/src/shell/cmd_icd.ts @@ -4,22 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Duration, Millis, ObserverGroup } from "@matter/general"; -import { IcdClient, IcdMultiAdminError } from "@matter/node"; +import { Duration, ImplementationError, Millis, ObserverGroup } from "@matter/general"; +import { ClientNode, IcdClient, IcdMultiAdminError, NodeConnectionState } from "@matter/node"; import { IcdManagementClient } from "@matter/node/behaviors/icd-management"; import { NodeId, SubjectId, VendorId } from "@matter/types"; import { IcdManagement } from "@matter/types/clusters/icd-management"; -import { NodeStates, PairedNode } from "@project-chip/matter.js/device"; import type { Argv } from "yargs"; import { MatterNode } from "../MatterNode.js"; +import { awaitSeeded } from "../util/awaitSeeded.js"; const watchers = new Map(); -async function printStatus(paired: PairedNode) { - const clientNode = paired.node; +/** Look up a commissioned peer by node id without connecting or awaiting remote initialization. */ +function findCommissionedNode(theNode: MatterNode, nodeId: NodeId): ClientNode | undefined { + return theNode.node.peers.commissioned.find(peer => peer.peerAddress?.nodeId === nodeId); +} + +async function printStatus(nodeId: NodeId, clientNode: ClientNode) { if (!clientNode.behaviors.has(IcdClient)) { - if (!paired.initialized) { - console.log(`node ${paired.nodeId}: uninitialized (no cached data yet)`); + if (!clientNode.lifecycle.isSeeded) { + console.log(`node ${nodeId}: uninitialized (no cached data yet)`); } return; } @@ -31,10 +35,10 @@ async function printStatus(paired: PairedNode) { mgmt === undefined ? "n/a" : (IcdManagement.OperatingMode[mgmt.operatingMode ?? -1] ?? mgmt.operatingMode ?? "unknown"); - const marker = paired.initialized ? "" : " [uninitialized]"; + const marker = clientNode.lifecycle.isSeeded ? "" : " [uninitialized]"; console.log( [ - `node ${paired.nodeId}:${marker}`, + `node ${nodeId}:${marker}`, ` registered=${icd.registered}`, ` available=${icd.available}`, ` awake=${awake}`, @@ -58,7 +62,12 @@ function selectsAll(nodeIdStr: string) { /** Resolve a node-id argument to concrete ids: `all` expands to every commissioned node, otherwise a single id. */ async function targetNodeIds(theNode: MatterNode, nodeIdStr: string): Promise { await theNode.start(); - return selectsAll(nodeIdStr) ? theNode.controller.getCommissionedNodes() : [NodeId(BigInt(nodeIdStr))]; + if (!selectsAll(nodeIdStr)) { + return [NodeId(BigInt(nodeIdStr))]; + } + return theNode.node.peers.commissioned + .map(peer => peer.peerAddress?.nodeId) + .filter((nodeId): nodeId is NodeId => nodeId !== undefined); } /** @@ -84,19 +93,20 @@ async function eachNode(theNode: MatterNode, nodeIdStr: string, action: (nodeId: } } -async function pairedNodeFor(theNode: MatterNode, nodeId: NodeId) { - const paired = (await theNode.connectAndGetNodes(String(nodeId)))[0]; - if (paired === undefined) { - throw new Error(`Node ${nodeId} not found / not connected`); +/** Connect to `nodeId` and verify it exposes IcdManagement, throwing an {@link ImplementationError} otherwise. */ +async function connectedIcdNode(theNode: MatterNode, nodeId: NodeId): Promise { + const clientNode = (await theNode.connectAndGetNodes(String(nodeId)))[0]; + if (clientNode === undefined) { + throw new ImplementationError(`Node ${nodeId} not found / not connected`); } - if (!paired.node.behaviors.has(IcdClient)) { - throw new Error(`Node ${nodeId} is not an ICD device (no IcdManagement cluster)`); + // Behaviors populate as part of seeding; await it so a not-yet-seeded peer isn't misread as non-ICD. + if (!(await awaitSeeded(clientNode, { quiet: true }))) { + throw new ImplementationError(`Node ${nodeId} did not become ready (offline?)`); } - return paired; -} - -async function clientNodeFor(theNode: MatterNode, nodeId: NodeId) { - return (await pairedNodeFor(theNode, nodeId)).node; + if (!clientNode.behaviors.has(IcdClient)) { + throw new ImplementationError(`Node ${nodeId} is not an ICD device (no IcdManagement cluster)`); + } + return clientNode; } function stopWatch(nodeIdStr: string) { @@ -120,8 +130,7 @@ async function startWatch(theNode: MatterNode, nodeId: NodeId) { watchers.get(key)?.close(); watchers.delete(key); - const paired = await pairedNodeFor(theNode, nodeId); - const clientNode = paired.node; + const clientNode = await connectedIcdNode(theNode, nodeId); const observers = new ObserverGroup(); const stamp = (msg: string) => console.log(`[icd ${nodeId}] ${msg}`); // Event emitters discard emit()'s promise, so a rejection from this async read must not escape. @@ -145,10 +154,12 @@ async function startWatch(theNode: MatterNode, nodeId: NodeId) { observers.on(events.checkInMissed, async () => stamp(`check-in MISSED ${await wakefulnessSuffix()}`)); observers.on(events.keyRefreshed, () => stamp("key refreshed")); observers.on(events.available$Changed, (value: boolean) => stamp(`available=${value}`)); - observers.on(paired.events.stateChanged, state => stamp(`connection=${NodeStates[state] ?? state}`)); + observers.on(clientNode.lifecycle.connectionStateChanged, state => + stamp(`connection=${NodeConnectionState[state] ?? state}`), + ); watchers.set(key, observers); // Drop the watcher when the node goes away so it doesn't leak in the map. - clientNode.lifecycle.destroyed.once(() => { + observers.on(clientNode.lifecycle.destroyed, () => { watchers.get(key)?.close(); watchers.delete(key); }); @@ -198,7 +209,7 @@ export default function commands(theNode: MatterNode) { ignoredVendors: argv.ignoreVendor?.map((v: string | number) => VendorId(Number(v))), }; await eachNode(theNode, argv.nodeId, async nodeId => { - const clientNode = await clientNodeFor(theNode, nodeId); + const clientNode = await connectedIcdNode(theNode, nodeId); try { await clientNode.act(agent => agent.get(IcdClient).register(options)); console.log(`Registered as Check-In client on node ${nodeId}`); @@ -235,9 +246,14 @@ export default function commands(theNode: MatterNode) { if (argv.force) { // A registered-but-unreachable LIT peer parks every connecting/peer-I/O path on its // never-coming Check-In; reach it via the non-connecting accessor and forget() locally. - const clientNode = (await theNode.controller.getNode(nodeId)).node; + const clientNode = findCommissionedNode(theNode, nodeId); + if (clientNode === undefined) { + throw new ImplementationError(`Node ${nodeId} is not commissioned`); + } if (!clientNode.behaviors.has(IcdClient)) { - throw new Error(`Node ${nodeId} is not an ICD device (no IcdManagement cluster)`); + throw new ImplementationError( + `Node ${nodeId} is not an ICD device (no IcdManagement cluster)`, + ); } await clientNode.act(agent => agent.get(IcdClient).forget()); console.log( @@ -245,7 +261,7 @@ export default function commands(theNode: MatterNode) { ); return; } - const clientNode = await clientNodeFor(theNode, nodeId); + const clientNode = await connectedIcdNode(theNode, nodeId); await clientNode.act(agent => agent.get(IcdClient).unregister()); console.log(`Unregistered Check-In client on node ${nodeId}`); }); @@ -269,7 +285,7 @@ export default function commands(theNode: MatterNode) { }), handler: async (argv: any) => { await eachNode(theNode, argv.nodeId, async nodeId => { - const clientNode = await clientNodeFor(theNode, nodeId); + const clientNode = await connectedIcdNode(theNode, nodeId); const promised = await clientNode.act(agent => agent.get(IcdClient).stayActive(Millis(argv.durationMs)), ); @@ -283,7 +299,7 @@ export default function commands(theNode: MatterNode) { builder: (y: Argv) => y.positional("node-id", { describe: "node id, or 'all'", type: "string", demandOption: true }), handler: async (argv: any) => { - // getNode() never awaits remote init, unlike connectAndGetNodes(), so a peer stuck mid-read can't hang this command. + // findCommissionedNode never connects/awaits remote init, so a peer stuck mid-read can't hang this command. const nodeIds = await targetNodeIds(theNode, argv.nodeId); if (nodeIds.length === 0) { console.log("No commissioned nodes."); @@ -292,7 +308,11 @@ export default function commands(theNode: MatterNode) { // Diagnostic command: report an unreachable node inline rather than aborting, even for a single id. for (const nodeId of nodeIds) { try { - await printStatus(await theNode.controller.getNode(nodeId)); + const clientNode = findCommissionedNode(theNode, nodeId); + if (clientNode === undefined) { + throw new ImplementationError(`Node ${nodeId} is not commissioned`); + } + await printStatus(nodeId, clientNode); } catch (e) { console.log( `node ${nodeId}: unavailable (${e instanceof Error ? e.message : String(e)})`, diff --git a/packages/nodejs-shell/src/shell/cmd_identify.ts b/packages/nodejs-shell/src/shell/cmd_identify.ts index e3ca4cf124..020cdbbcee 100644 --- a/packages/nodejs-shell/src/shell/cmd_identify.ts +++ b/packages/nodejs-shell/src/shell/cmd_identify.ts @@ -4,9 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Identify } from "@matter/types/clusters"; +import { IdentifyClient } from "@matter/node/behaviors/identify"; import type { Argv } from "yargs"; import { MatterNode } from "../MatterNode.js"; +import { awaitSeeded } from "../util/awaitSeeded.js"; export default function commands(theNode: MatterNode) { return { @@ -34,17 +35,20 @@ export default function commands(theNode: MatterNode) { handler: async (argv: any) => { const { nodeId, time = 10, endpointId } = argv; + const nodes = await theNode.connectAndGetNodes(nodeId); + for (const node of nodes) { + await awaitSeeded(node); + } await theNode.iterateNodeDevices( - await theNode.connectAndGetNodes(nodeId), + nodes, async (device, node) => { - const identifyCluster = device.getClusterClient(Identify); - if (identifyCluster === undefined) { + if (device.number === 0 || !device.behaviors.has(IdentifyClient)) { return; } - console.log("Invoke Identify for", node.nodeId.toString()); - await identifyCluster.identify({ identifyTime: time }); + console.log("Invoke Identify for", node.peerAddress?.nodeId.toString()); + await device.act(agent => agent.get(IdentifyClient).identify({ identifyTime: time })); }, - endpointId, + endpointId !== undefined ? [endpointId] : undefined, ); }, }; diff --git a/packages/nodejs-shell/src/shell/cmd_nodes.ts b/packages/nodejs-shell/src/shell/cmd_nodes.ts index 55583ef676..d70293df5e 100644 --- a/packages/nodejs-shell/src/shell/cmd_nodes.ts +++ b/packages/nodejs-shell/src/shell/cmd_nodes.ts @@ -4,19 +4,44 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { capitalize, ChannelType, decamelize, Diagnostic, ServerAddress } from "@matter/general"; -import { ClientNode, CommissioningClient, NetworkClient, SoftwareUpdateManager } from "@matter/node"; -import { PeerAddress, PeerSet } from "@matter/protocol"; -import { FabricIndex, NodeId, VendorId } from "@matter/types"; -import { NodeStateInformation } from "@project-chip/matter.js/device"; +import { + capitalize, + ChannelType, + decamelize, + Diagnostic, + ImplementationError, + InternalError, + Logger, + ServerAddress, +} from "@matter/general"; +import { + ClientNode, + CommissioningClient, + NetworkClient, + NodeConnectionState, + SoftwareUpdateManager, +} from "@matter/node"; +import { BasicInformationClient } from "@matter/node/behaviors/basic-information"; +import { FabricAuthority, PeerSet } from "@matter/protocol"; +import { NodeId } from "@matter/types"; import type { Argv } from "yargs"; -import { createDiagnosticCallbacks, MatterNode } from "../MatterNode.js"; +import { MatterNode } from "../MatterNode.js"; +import { awaitSeeded } from "../util/awaitSeeded.js"; + +const logger = Logger.get("cmd_nodes"); + +/** Look up a commissioned peer by node id across the controller's default admin fabric. */ +function findCommissionedNode(theNode: MatterNode, nodeId: NodeId): ClientNode | undefined { + return theNode.node.peers.commissioned.find(peer => peer.peerAddress?.nodeId === nodeId); +} /** Parse a `udp://host:port` / `tcp://host:port` URL (IPv6 host in brackets) into a {@link ServerAddress}. */ function parseFallbackAddress(input: string): ServerAddress { const match = /^(udp|tcp):\/\/(.+)$/i.exec(input); if (!match) { - throw new Error(`Invalid address "${input}". Expected udp://: or tcp://:`); + throw new ImplementationError( + `Invalid address "${input}". Expected udp://: or tcp://:`, + ); } const type = match[1].toLowerCase() === "tcp" ? "tcp" : "udp"; const rest = match[2]; @@ -26,14 +51,14 @@ function parseFallbackAddress(input: string): ServerAddress { if (rest.startsWith("[")) { const end = rest.indexOf("]"); if (end === -1 || rest[end + 1] !== ":") { - throw new Error(`Invalid IPv6 address "${input}". Expected ${type}://[]:`); + throw new ImplementationError(`Invalid IPv6 address "${input}". Expected ${type}://[]:`); } ip = rest.slice(1, end); portStr = rest.slice(end + 2); } else { const idx = rest.lastIndexOf(":"); if (idx === -1) { - throw new Error(`Missing port in "${input}". Expected ${type}://:`); + throw new ImplementationError(`Missing port in "${input}". Expected ${type}://:`); } ip = rest.slice(0, idx); portStr = rest.slice(idx + 1); @@ -41,7 +66,9 @@ function parseFallbackAddress(input: string): ServerAddress { const port = Number(portStr); if (!ip.length || !Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid host/port in "${input}". Expected ${type}://: with port 1-65535`); + throw new ImplementationError( + `Invalid host/port in "${input}". Expected ${type}://: with port 1-65535`, + ); } return { ip, port, type }; @@ -67,27 +94,27 @@ export default function commands(theNode: MatterNode) { async argv => { const { status } = argv; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } + const peers = theNode.node.peers.commissioned; switch (status) { case "commissioned": { - const details = theNode.commissioningController.getCommissionedNodesDetails(); - details - .map(detail => ({ - ...detail, - nodeId: detail.nodeId.toString(), - })) - .forEach(detail => { - console.log(detail); + for (const peer of peers) { + const { addresses, deviceName } = peer.state.commissioning; + console.log({ + nodeId: peer.peerAddress?.nodeId.toString(), + operationalAddress: addresses?.length + ? ServerAddress.urlFor(addresses[0]) + : undefined, + advertisedName: deviceName, + basicInformation: peer.maybeStateOf(BasicInformationClient), }); + } break; } case "connected": { - const nodeIds = theNode.commissioningController - .getCommissionedNodes() - .filter(nodeId => !!theNode.commissioningController?.getPairedNode(nodeId)); - console.log(nodeIds.map(nodeId => nodeId.toString())); + const nodeIds = peers + .filter(peer => peer.lifecycle.isConnected) + .map(peer => peer.peerAddress?.nodeId); + console.log(nodeIds.map(nodeId => nodeId?.toString())); break; } } @@ -106,9 +133,12 @@ export default function commands(theNode: MatterNode) { async argv => { const { nodeId } = argv; const node = (await theNode.connectAndGetNodes(nodeId))[0]; + if (!(await awaitSeeded(node))) { + return; + } - console.log("Logging structure of Node ", node.nodeId.toString()); - node.logStructure(); + console.log("Logging structure of Node ", node.peerAddress?.nodeId.toString()); + logger.info(node); }, ) .command( @@ -124,12 +154,13 @@ export default function commands(theNode: MatterNode) { async argv => { const { nodeId: nodeIdStr } = argv; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } const nodeId = NodeId(BigInt(nodeIdStr)); - const peerAddress = theNode.commissioningController.fabric.addressOf(nodeId); + const peerAddress = findCommissionedNode(theNode, nodeId)?.peerAddress; + if (peerAddress === undefined) { + console.log(`Node ${nodeIdStr} not commissioned`); + return; + } const peerSet = theNode.node.env.get(PeerSet); const peer = peerSet.for(peerAddress); @@ -208,30 +239,13 @@ export default function commands(theNode: MatterNode) { }, async argv => { const { nodeId: nodeIdStr, maxSubscriptionInterval, minSubscriptionInterval } = argv; - await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } - let nodeIds = theNode.commissioningController.getCommissionedNodes(); - if (nodeIdStr !== "all") { - const cmdNodeId = NodeId(BigInt(nodeIdStr)); - nodeIds = nodeIds.filter(nodeId => nodeId === cmdNodeId); - if (!nodeIds.length) { - throw new Error(`Node ${nodeIdStr} not commissioned`); - } - } - const autoSubscribe = minSubscriptionInterval !== undefined; - for (const nodeIdToProcess of nodeIds) { - const node = await theNode.commissioningController.getNode(nodeIdToProcess); - node.connect({ - autoSubscribe, - subscribeMinIntervalFloorSeconds: autoSubscribe ? minSubscriptionInterval : undefined, - subscribeMaxIntervalCeilingSeconds: autoSubscribe ? maxSubscriptionInterval : undefined, - ...createDiagnosticCallbacks(), - }); - } + await theNode.connectAndGetNodes(nodeIdStr !== "all" ? nodeIdStr : undefined, { + autoSubscribe, + subscribeMinIntervalFloorSeconds: autoSubscribe ? minSubscriptionInterval : undefined, + subscribeMaxIntervalCeilingSeconds: autoSubscribe ? maxSubscriptionInterval : undefined, + }); }, ) .command( @@ -246,27 +260,21 @@ export default function commands(theNode: MatterNode) { }, async argv => { const { nodeId: nodeIdStr } = argv; - if (theNode.commissioningController === undefined) { - console.log("Controller not initialized, nothing to disconnect."); - return; - } + await theNode.start(); - let nodeIds = theNode.commissioningController.getCommissionedNodes(); + let nodes = theNode.node.peers.commissioned; if (nodeIdStr !== "all") { const cmdNodeId = NodeId(BigInt(nodeIdStr)); - nodeIds = nodeIds.filter(nodeId => nodeId === cmdNodeId); - if (!nodeIds.length) { - throw new Error(`Node ${nodeIdStr} not commissioned`); + nodes = nodes.filter(node => node.peerAddress?.nodeId === cmdNodeId); + if (!nodes.length) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); } } - for (const nodeIdToProcess of nodeIds) { - const node = theNode.commissioningController.getPairedNode(nodeIdToProcess); - if (node === undefined) { - console.log(`Node ${nodeIdToProcess} not connected`); - continue; - } - await node.disconnect(); + for (const node of nodes) { + // Stop (not disable): peers stay enabled for on-demand reconnect, and the sweep being off + // keeps them disconnected across restarts anyway. + await node.stop(); } }, ) @@ -284,33 +292,25 @@ export default function commands(theNode: MatterNode) { async argv => { const { nodeIds: nodeIdStr } = argv; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } - let nodeIds = theNode.commissioningController.getCommissionedNodes(); + let nodes = theNode.node.peers.commissioned; if (nodeIdStr !== "all") { const nodeIdList = nodeIdStr.split(",").map(nodeId => NodeId(BigInt(nodeId))); - nodeIds = nodeIds.filter(nodeId => nodeIdList.includes(nodeId)); - if (!nodeIds.length) { - throw new Error(`Node ${nodeIdStr} not commissioned`); + nodes = nodes.filter( + node => node.peerAddress !== undefined && nodeIdList.includes(node.peerAddress.nodeId), + ); + if (!nodes.length) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); } } - const nodeDetails = theNode.commissioningController.getCommissionedNodesDetails(); - - for (const nodeIdToProcess of nodeIds) { - const node = theNode.commissioningController.getPairedNode(nodeIdToProcess); - if (node === undefined) { - const details = nodeDetails.find(nd => nd.nodeId === nodeIdToProcess); - console.log( - `Node ${nodeIdToProcess}: Not initialized${details?.deviceData?.basicInformation !== undefined ? ` (${details.deviceData.basicInformation.vendorName} ${details.deviceData.basicInformation.productName})` : ""}`, - ); - } else { - const basicInfo = node.basicInformation; - console.log( - `Node ${nodeIdToProcess}: Node Status: ${capitalize(decamelize(NodeStateInformation[node.connectionState], " "))}${basicInfo !== undefined ? ` (${basicInfo.vendorName} ${basicInfo.productName})` : ""}`, - ); - } + for (const node of nodes) { + const basicInfo = node.maybeStateOf(BasicInformationClient); + const status = capitalize( + decamelize(NodeConnectionState[node.lifecycle.connectionState], " "), + ); + console.log( + `Node ${node.peerAddress?.nodeId}: Node Status: ${status}${basicInfo !== undefined ? ` (${basicInfo.vendorName} ${basicInfo.productName})` : ""}`, + ); } }, ) @@ -335,20 +335,22 @@ export default function commands(theNode: MatterNode) { async argv => { const { nodeId: nodeIdStr, preference } = argv; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } const nodeId = NodeId(BigInt(nodeIdStr)); - const node = await theNode.commissioningController.getNode(nodeId); + const node = findCommissionedNode(theNode, nodeId); + if (node === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); + } + const peerAddress = node.peerAddress; + if (peerAddress === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} has no peer address`); + } const pref = preference === "on" ? "tcp" : preference === "off" ? "udp" : undefined; - await node.node.setStateOf(NetworkClient, { transportPreference: pref }); + await node.setStateOf(NetworkClient, { transportPreference: pref }); // Also update the protocol-level peer preference - const peer = theNode.node.env - .get(PeerSet) - .for(theNode.commissioningController.fabric.addressOf(nodeId)); + const peer = theNode.node.env.get(PeerSet).for(peerAddress); if (peer) { peer.transportPreference = pref === "tcp" ? ChannelType.TCP : undefined; } @@ -376,13 +378,13 @@ export default function commands(theNode: MatterNode) { async argv => { const { nodeId: nodeIdStr } = argv; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } const nodeId = NodeId(BigInt(nodeIdStr)); - const node = await theNode.commissioningController.getNode(nodeId); - const addresses = node.node.state.commissioning.addresses; + const node = findCommissionedNode(theNode, nodeId); + if (node === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); + } + const addresses = node.state.commissioning.addresses; if (!addresses?.length) { console.log(`Node ${nodeIdStr} has no fallback addresses stored`); @@ -414,21 +416,21 @@ export default function commands(theNode: MatterNode) { async argv => { const { nodeId: nodeIdStr, address: addressStr } = argv; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } const nodeId = NodeId(BigInt(nodeIdStr)); - const node = await theNode.commissioningController.getNode(nodeId); + const node = findCommissionedNode(theNode, nodeId); + if (node === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); + } if (addressStr === "drop") { - await node.node.setStateOf(CommissioningClient, { addresses: undefined }); + await node.setStateOf(CommissioningClient, { addresses: undefined }); console.log(`Fallback address for node ${nodeIdStr} dropped`); return; } const address = parseFallbackAddress(addressStr); - await node.node.setStateOf(CommissioningClient, { addresses: [address] }); + await node.setStateOf(CommissioningClient, { addresses: [address] }); console.log( `Fallback address for node ${nodeIdStr} set to ${ServerAddress.urlFor(address)}`, ); @@ -464,29 +466,25 @@ export default function commands(theNode: MatterNode) { async argv => { const { nodeId: nodeIdStr, maxSubscriptionInterval, minSubscriptionInterval } = argv; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } - let nodeIds = theNode.commissioningController.getCommissionedNodes(); const cmdNodeId = NodeId(BigInt(nodeIdStr)); - nodeIds = nodeIds.filter(nodeId => nodeId === cmdNodeId); - if (nodeIds.length) { - throw new Error(`Node ${nodeIdStr} already known`); + if (findCommissionedNode(theNode, cmdNodeId) !== undefined) { + throw new ImplementationError(`Node ${nodeIdStr} already known`); } - await theNode.commissioningController.node.peers.forAddress( - theNode.commissioningController.fabric.addressOf(cmdNodeId), - ); + // Single-fabric shell: the controller's own (first-owned) fabric is the one to address peers on. + const fabric = theNode.node.env.get(FabricAuthority).fabrics[0]; + if (fabric === undefined) { + throw new InternalError("No controller fabric present after start"); + } + await theNode.node.peers.forAddress(fabric.addressOf(cmdNodeId)); const autoSubscribe = minSubscriptionInterval !== undefined; - const node = await theNode.commissioningController.getNode(cmdNodeId); - node.connect({ + await theNode.connectAndGetNodes(nodeIdStr, { autoSubscribe, subscribeMinIntervalFloorSeconds: autoSubscribe ? minSubscriptionInterval : undefined, subscribeMaxIntervalCeilingSeconds: autoSubscribe ? maxSubscriptionInterval : undefined, - ...createDiagnosticCallbacks(), }); }, ) @@ -515,21 +513,20 @@ export default function commands(theNode: MatterNode) { const { nodeId: nodeIdStr, local } = argv; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } let peerToCheck: ClientNode | undefined = undefined; if (nodeIdStr !== undefined) { const nodeId = NodeId(BigInt(nodeIdStr)); - peerToCheck = (await theNode.commissioningController.getNode(nodeId))?.node; + peerToCheck = findCommissionedNode(theNode, nodeId); + if (peerToCheck === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); + } } - const updatesAvailable = await theNode.commissioningController.otaProvider.act( - agent => - agent - .get(SoftwareUpdateManager) - .queryUpdates({ peerToCheck, includeStoredUpdates: local }), + const updatesAvailable = await theNode.otaProviderEndpoint.act(agent => + agent + .get(SoftwareUpdateManager) + .queryUpdates({ peerToCheck, includeStoredUpdates: local }), ); if (updatesAvailable.length) { @@ -572,34 +569,32 @@ export default function commands(theNode: MatterNode) { const isProduction = mode === "prod" ? true : mode === "test" ? false : undefined; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } const nodeId = NodeId(BigInt(nodeIdStr)); - const nodeDetails = theNode.commissioningController - .getCommissionedNodesDetails() - .find(nd => nd.nodeId === nodeId); - const basicInfo = nodeDetails?.deviceData?.basicInformation; - if (!basicInfo) { - throw new Error(`Node ${nodeIdStr} has no basic information available`); + const node = findCommissionedNode(theNode, nodeId); + if (node === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); + } + const basicInfo = node.maybeStateOf(BasicInformationClient); + if (basicInfo === undefined) { + throw new ImplementationError( + `Node ${nodeIdStr} has no basic information available`, + ); } if ( basicInfo.vendorId === undefined || basicInfo.productId === undefined || basicInfo.softwareVersion === undefined ) { - throw new Error( - `Node ${nodeIdStr} is missing required basic information for OTA check`, + throw new ImplementationError( + `Node ${nodeIdStr} BasicInformation is incomplete; connect the node first`, ); } console.log(`Checking for OTA updates for node ${nodeIdStr}...`); + console.log(` Vendor ID: ${Diagnostic.hex(basicInfo.vendorId, 4).toUpperCase()}`); console.log( - ` Vendor ID: ${Diagnostic.hex(basicInfo.vendorId as VendorId, 4).toUpperCase()}`, - ); - console.log( - ` Product ID: ${Diagnostic.hex(basicInfo.productId as number, 4).toUpperCase()}`, + ` Product ID: ${Diagnostic.hex(basicInfo.productId, 4).toUpperCase()}`, ); console.log( ` Current Software Version: ${basicInfo.softwareVersion} (${basicInfo.softwareVersionString})`, @@ -609,9 +604,9 @@ export default function commands(theNode: MatterNode) { const updateInfo = await ( await theNode.otaService() ).checkForUpdate({ - vendorId: basicInfo.vendorId as VendorId, - productId: basicInfo.productId as number, - currentSoftwareVersion: basicInfo.softwareVersion as number, + vendorId: basicInfo.vendorId, + productId: basicInfo.productId, + currentSoftwareVersion: basicInfo.softwareVersion, includeStoredUpdates: local, isProduction, }); @@ -670,34 +665,32 @@ export default function commands(theNode: MatterNode) { const forceDownload = force === true; await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } const nodeId = NodeId(BigInt(nodeIdStr)); - const nodeDetails = theNode.commissioningController - .getCommissionedNodesDetails() - .find(nd => nd.nodeId === nodeId); - const basicInfo = nodeDetails?.deviceData?.basicInformation; - if (!basicInfo) { - throw new Error(`Node ${nodeIdStr} has no basic information available`); + const node = findCommissionedNode(theNode, nodeId); + if (node === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); + } + const basicInfo = node.maybeStateOf(BasicInformationClient); + if (basicInfo === undefined) { + throw new ImplementationError( + `Node ${nodeIdStr} has no basic information available`, + ); } if ( basicInfo.vendorId === undefined || basicInfo.productId === undefined || basicInfo.softwareVersion === undefined ) { - throw new Error( - `Node ${nodeIdStr} is missing required basic information for OTA check`, + throw new ImplementationError( + `Node ${nodeIdStr} BasicInformation is incomplete; connect the node first`, ); } console.log(`Checking for OTA updates for node ${nodeIdStr}...`); + console.log(` Vendor ID: ${Diagnostic.hex(basicInfo.vendorId, 4).toUpperCase()}`); console.log( - ` Vendor ID: ${Diagnostic.hex(basicInfo.vendorId as VendorId, 4).toUpperCase()}`, - ); - console.log( - ` Product ID: ${Diagnostic.hex(basicInfo.productId as number, 4).toUpperCase()}`, + ` Product ID: ${Diagnostic.hex(basicInfo.productId, 4).toUpperCase()}`, ); console.log( ` Current Software Version: ${basicInfo.softwareVersion} (${basicInfo.softwareVersionString})`, @@ -707,9 +700,9 @@ export default function commands(theNode: MatterNode) { const updateInfo = await ( await theNode.otaService() ).checkForUpdate({ - vendorId: basicInfo.vendorId as VendorId, - productId: basicInfo.productId as number, - currentSoftwareVersion: basicInfo.softwareVersion as number, + vendorId: basicInfo.vendorId, + productId: basicInfo.productId, + currentSoftwareVersion: basicInfo.softwareVersion, includeStoredUpdates: local, isProduction, }); @@ -775,34 +768,31 @@ export default function commands(theNode: MatterNode) { await theNode.start(); - if (theNode.commissioningController === undefined) { - throw new Error("CommissioningController not initialized"); - } - const nodeId = NodeId(BigInt(nodeIdStr)); - const nodeDetails = theNode.commissioningController - .getCommissionedNodesDetails() - .find(nd => nd.nodeId === nodeId); - const basicInfo = nodeDetails?.deviceData?.basicInformation; - if (!basicInfo) { - throw new Error(`Node ${nodeIdStr} has no basic information available`); + const node = findCommissionedNode(theNode, nodeId); + if (node === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} not commissioned`); + } + const basicInfo = node.maybeStateOf(BasicInformationClient); + if (basicInfo === undefined) { + throw new ImplementationError( + `Node ${nodeIdStr} has no basic information available`, + ); } if ( basicInfo.vendorId === undefined || basicInfo.productId === undefined || basicInfo.softwareVersion === undefined ) { - throw new Error( - `Node ${nodeIdStr} is missing required basic information for OTA check`, + throw new ImplementationError( + `Node ${nodeIdStr} BasicInformation is incomplete; connect the node first`, ); } console.log(`Checking for OTA updates for node ${nodeIdStr}...`); + console.log(` Vendor ID: ${Diagnostic.hex(basicInfo.vendorId, 4).toUpperCase()}`); console.log( - ` Vendor ID: ${Diagnostic.hex(basicInfo.vendorId as VendorId, 4).toUpperCase()}`, - ); - console.log( - ` Product ID: ${Diagnostic.hex(basicInfo.productId as number, 4).toUpperCase()}`, + ` Product ID: ${Diagnostic.hex(basicInfo.productId, 4).toUpperCase()}`, ); console.log( ` Current Software Version: ${basicInfo.softwareVersion} (${basicInfo.softwareVersionString})`, @@ -812,9 +802,9 @@ export default function commands(theNode: MatterNode) { const localUpdates = await ( await theNode.otaService() ).find({ - vendorId: basicInfo.vendorId as VendorId, - productId: basicInfo.productId as number, - currentVersion: basicInfo.softwareVersion as number, + vendorId: basicInfo.vendorId, + productId: basicInfo.productId, + currentVersion: basicInfo.softwareVersion, }); if (local && !localUpdates.length) { @@ -825,9 +815,9 @@ export default function commands(theNode: MatterNode) { const updateInfo = await ( await theNode.otaService() ).checkForUpdate({ - vendorId: basicInfo.vendorId as VendorId, - productId: basicInfo.productId as number, - currentSoftwareVersion: basicInfo.softwareVersion as number, + vendorId: basicInfo.vendorId, + productId: basicInfo.productId, + currentSoftwareVersion: basicInfo.softwareVersion, includeStoredUpdates: local, isProduction, }); @@ -864,18 +854,21 @@ export default function commands(theNode: MatterNode) { ); } - const node = theNode.commissioningController.getPairedNode(nodeId); - if (node === undefined) { - throw new Error(`Node ${nodeIdStr} not connected`); + if (!node.lifecycle.isConnected) { + throw new ImplementationError(`Node ${nodeIdStr} not connected`); + } + const peerAddress = node.peerAddress; + if (peerAddress === undefined) { + throw new ImplementationError(`Node ${nodeIdStr} has no peer address`); } - await theNode.commissioningController.otaProvider.act(agent => { + await theNode.otaProviderEndpoint.act(agent => { return agent .get(SoftwareUpdateManager) .forceUpdate( - PeerAddress({ nodeId, fabricIndex: FabricIndex(1) }), - basicInfo.vendorId as VendorId, - basicInfo.productId as number, + peerAddress, + basicInfo.vendorId, + basicInfo.productId, updateVersion, ); }); diff --git a/packages/nodejs-shell/src/shell/cmd_session.ts b/packages/nodejs-shell/src/shell/cmd_session.ts index 29103c6a28..4eebc6b67d 100644 --- a/packages/nodejs-shell/src/shell/cmd_session.ts +++ b/packages/nodejs-shell/src/shell/cmd_session.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { SessionsBehavior } from "@matter/node"; import { MatterNode } from "../MatterNode.js"; export default function commands(theNode: MatterNode) { @@ -12,11 +13,8 @@ export default function commands(theNode: MatterNode) { describe: "Manage session", builder: {}, handler: async () => { - if (!theNode.commissioningController) { - throw new Error("CommissioningController not initialized"); - } - - const sessions = theNode.commissioningController?.getActiveSessionInformation(); + await theNode.start(); + const sessions = Object.values(theNode.node.stateOf(SessionsBehavior).sessions); console.log(sessions); }, }; diff --git a/packages/nodejs-shell/src/shell/cmd_subscribe.ts b/packages/nodejs-shell/src/shell/cmd_subscribe.ts index ed5639cff7..acc115247b 100644 --- a/packages/nodejs-shell/src/shell/cmd_subscribe.ts +++ b/packages/nodejs-shell/src/shell/cmd_subscribe.ts @@ -4,10 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Diagnostic } from "@matter/general"; +import { ImplementationError, ObserverGroup } from "@matter/general"; +import { NetworkClient } from "@matter/node"; import type { Argv } from "yargs"; import { MatterNode } from "../MatterNode.js"; +const watchers = new Map(); + export default function commands(theNode: MatterNode) { return { command: "subscribe [node-id]", @@ -23,21 +26,41 @@ export default function commands(theNode: MatterNode) { handler: async (argv: any) => { const { nodeId: subscribeNodeId } = argv; const node = (await theNode.connectAndGetNodes(subscribeNodeId))[0]; + if (node === undefined) { + throw new ImplementationError("No commissioned node to subscribe to"); + } + const nodeId = node.peerAddress?.nodeId; + if (nodeId === undefined) { + throw new ImplementationError("Resolved node has no peer address to subscribe to"); + } + const key = String(nodeId); + + // Re-subscribing the same node replaces its watcher so change lines are not logged twice. + watchers.get(key)?.close(); + watchers.delete(key); + const observers = new ObserverGroup(); + watchers.set(key, observers); + observers.on(node.lifecycle.destroyed, () => { + watchers.get(key)?.close(); + watchers.delete(key); + }); - await node.subscribeAllAttributesAndEvents({ - attributeChangedCallback: ({ path: { nodeId, clusterId, endpointId, attributeName }, value }) => - console.log( - `${subscribeNodeId}: Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Diagnostic.json( - value, - )}`, - ), - eventTriggeredCallback: ({ path: { nodeId, clusterId, endpointId, eventName }, events }) => - console.log( - `${subscribeNodeId} Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Diagnostic.json( - events, - )}`, - ), + // Attribute/event/structure changes for every peer are logged node-wide by installDiagnosticLogging + // (MatterNode.start); this command only establishes the subscription and reports its liveness. + + // Surface subscription liveness transitions (establishment, drop, re-establishment). + observers.on(node.eventsOf(NetworkClient).subscriptionStatusChanged, isActive => { + console.log(`${nodeId}: subscription ${isActive ? "active" : "inactive"}`); }); + + // Enabling auto-subscribe establishes (and thereafter re-establishes) the sustained subscription; changes + // flow to the already-registered listener above. + await node.set({ network: { autoSubscribe: true } }); + + const subscriptionActive = await node.act(agent => agent.get(NetworkClient).subscriptionActive); + console.log( + `Subscribed to node ${nodeId} (subscription active: ${subscriptionActive}). Attribute and event changes will be logged below as they arrive.`, + ); }, }; } diff --git a/packages/nodejs-shell/src/shell/webassets/index.html b/packages/nodejs-shell/src/shell/webassets/index.html index 727456a040..d10181af3f 100644 --- a/packages/nodejs-shell/src/shell/webassets/index.html +++ b/packages/nodejs-shell/src/shell/webassets/index.html @@ -285,7 +285,7 @@

matter.js Web Shell Example

logMessage(`Received: "${trimmed}"`, trimmed.toLowerCase().includes('error') ? 'error' : 'received'); // result of nodes log command - let matches = message.match(/INFO\s+PairedNode [^0-9]*(\d+)\D*/); + let matches = message.match(/Logging structure of Node\s+(\d+)/); if (matches) { // could save this message of extensive node data for later use let currentNode = matches[1]; let currentEndpoint = null; diff --git a/packages/nodejs-shell/src/util/ClusterEndpoint.ts b/packages/nodejs-shell/src/util/ClusterEndpoint.ts new file mode 100644 index 0000000000..ca8dddd88a --- /dev/null +++ b/packages/nodejs-shell/src/util/ClusterEndpoint.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ClientNode, ClusterBehavior, Endpoint } from "@matter/node"; +import { Read, ReadResult } from "@matter/protocol"; +import { AttributePath, ClusterId, EventPath, Status } from "@matter/types"; +import { MatterNode } from "../MatterNode.js"; +import { awaitSeeded } from "./awaitSeeded.js"; + +export interface ResolvedClusterEndpoint { + node: ClientNode; + endpoint: Endpoint; + behaviorType: ClusterBehavior.Type; +} + +/** + * Connect to `nodeIdStr`, wait for the peer's endpoint structure to seed, and resolve the behavior implementing + * `clusterId` on `endpointId`. Mirrors the legacy `getDeviceById(endpointId)?.getClusterClientById(clusterId)` lookup + * (same "not found" message on any failure) so the three cluster-access command files can share one gate. + */ +export async function resolveClusterEndpoint( + theNode: MatterNode, + nodeIdStr: string, + endpointId: number, + clusterId: number, +): Promise { + const node = (await theNode.connectAndGetNodes(nodeIdStr))[0]; + if (!(await awaitSeeded(node))) { + return undefined; + } + + const behaviorType = node.endpoints.has(endpointId) + ? node.endpoints.for(endpointId).behaviors.forCluster(ClusterId(clusterId)) + : undefined; + if (behaviorType === undefined) { + console.log(`ERROR: Cluster ${node.peerAddress?.nodeId}/${endpointId}/${clusterId} not found.`); + return undefined; + } + return { node, endpoint: node.endpoints.for(endpointId), behaviorType }; +} + +/** + * True only when `name` is a *known-absent* attribute/command of `behaviorType` on `endpoint`. + * + * The supported set derives from the behavior's global attribute/command list, which is EMPTY until that list has + * been read (a narrow window right after connect). An empty set is treated as "not yet known" — the caller proceeds + * to the live read/invoke rather than false-rejecting a genuinely supported element. Only a populated set that omits + * `name` is a real "unsupported" answer. + */ +export function elementKnownUnsupported( + endpoint: Endpoint, + behaviorType: ClusterBehavior.Type, + kind: "attributes" | "commands", + name: string, +): boolean { + const elements = endpoint.behaviors.elementsOf(behaviorType)[kind]; + return elements.size > 0 && !elements.has(name); +} + +/** + * Live (un-cached) attribute read via the interaction protocol. Used for `--remote` reads and for the numeric + * by-id command, which (like its legacy predecessor) must work for clusters the node has no behavior for. + */ +export async function readAttributesRemote( + node: ClientNode, + attributes: AttributePath[], + isFabricFiltered: boolean, +): Promise { + const values = new Array(); + for await (const chunk of node.interaction.read(Read({ attributes, fabricFilter: isFabricFiltered }))) { + for await (const report of chunk) { + if (report.kind === "attr-value") { + values.push(report); + } else if (report.kind === "attr-status") { + // A device-declined read (e.g. UnsupportedAttribute/UnsupportedAccess) otherwise reads as empty. + const { path, status } = report; + console.log( + `ERROR: ${path.endpointId}/${path.clusterId}/${path.attributeId}: ${Status[status] ?? status}`, + ); + } + } + } + return values; +} + +/** Live event read via the interaction protocol (there is no local event cache to read from instead). */ +export async function readEventsRemote( + node: ClientNode, + events: EventPath[], + isFabricFiltered: boolean, +): Promise { + const values = new Array(); + for await (const chunk of node.interaction.read(Read({ events, fabricFilter: isFabricFiltered }))) { + for await (const report of chunk) { + if (report.kind === "event-value") { + values.push(report); + } else if (report.kind === "event-status") { + const { path, status } = report; + console.log(`ERROR: ${path.endpointId}/${path.clusterId}/${path.eventId}: ${Status[status] ?? status}`); + } + } + } + return values; +} diff --git a/packages/nodejs-shell/src/util/awaitSeeded.ts b/packages/nodejs-shell/src/util/awaitSeeded.ts new file mode 100644 index 0000000000..0f58e9f502 --- /dev/null +++ b/packages/nodejs-shell/src/util/awaitSeeded.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Duration, ObserverGroup, Seconds, Time } from "@matter/general"; +import { ClientNode } from "@matter/node"; + +const DEFAULT_SEEDED_TIMEOUT = Seconds(60); + +/** + * Wait (bounded) for a peer's endpoint structure to seed before reading it. + * + * Legacy `await node.events.initialized` blocked forever for an offline peer; the direct replacement + * `await node.lifecycle.seeded` inherits that hang. This bounds the wait: it returns `true` once the node is seeded + * and, on timeout, prints an "offline?" notice and returns `false` so the caller can abort the command instead of + * hanging. + */ +export async function awaitSeeded( + node: ClientNode, + { timeout = DEFAULT_SEEDED_TIMEOUT, quiet = false }: { timeout?: Duration; quiet?: boolean } = {}, +): Promise { + if (node.lifecycle.isSeeded) { + return true; + } + + const observers = new ObserverGroup(); + const sleep = Time.sleep("awaitSeeded", timeout); + try { + const seeded = new Promise(resolve => observers.on(node.lifecycle.seeded, () => resolve())); + const isSeeded = await Promise.race([seeded.then(() => true), sleep.then(() => false)]); + if (!isSeeded && !quiet) { + console.log( + `Node ${node.peerAddress?.nodeId} did not become ready within ${Duration.format(timeout)} (offline?); giving up.`, + ); + } + return isSeeded; + } finally { + sleep.cancel(); + observers.close(); + } +} diff --git a/packages/nodejs-shell/src/util/diagnosticLogging.ts b/packages/nodejs-shell/src/util/diagnosticLogging.ts new file mode 100644 index 0000000000..f9de4bd4af --- /dev/null +++ b/packages/nodejs-shell/src/util/diagnosticLogging.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Diagnostic, ObserverGroup } from "@matter/general"; +import { + ChangeNotificationService, + ClientNode, + ClusterBehavior, + Endpoint, + NodeConnectionState, + ServerNode, +} from "@matter/node"; + +/** True if `endpoint` belongs to `node`'s endpoint tree (the node itself is its own root endpoint). */ +function ownedBy(endpoint: Endpoint, node: Endpoint) { + for (let e: Endpoint | undefined = endpoint; e !== undefined; e = e.owner) { + if (e === node) { + return true; + } + } + return false; +} + +function connectionStateLabel(state: NodeConnectionState) { + switch (state) { + case NodeConnectionState.Connected: + return "connected"; + case NodeConnectionState.Disconnected: + return "disconnected"; + case NodeConnectionState.Reconnecting: + return "reconnecting"; + case NodeConnectionState.WaitingForDeviceDiscovery: + return "waiting for device to be discovered again"; + } +} + +/** + * Wire node-wide diagnostic logging once for the controller and all its peers. + * + * Replaces the legacy per-connect `createDiagnosticCallbacks` (attribute/event/state callbacks passed into each + * `PairedNode.connect`), which the ClientNode API dropped. A single aggregate {@link ChangeNotificationService} stream + * covers attribute/event changes for every peer, and each peer's connection-state transitions are logged from its + * lifecycle. Registered handlers are owned by `observers` and torn down when it closes. + */ +export function installDiagnosticLogging(node: ServerNode, observers: ObserverGroup): void { + observers.on(node.env.get(ChangeNotificationService).change, change => { + const { endpoint } = change; + const peer = node.peers.commissioned.find(peer => ownedBy(endpoint, peer)); + if (peer === undefined) { + return; // A change on the controller's own node, not a peer. + } + const nodeId = peer.peerAddress?.nodeId; + + switch (change.kind) { + case "update": { + const { behavior, properties, version } = change; + if (!ClusterBehavior.is(behavior)) { + break; + } + const state = endpoint.stateOf(behavior.id); + const changed = + properties === undefined ? state : Object.fromEntries(properties.map(name => [name, state[name]])); + console.log( + `Node ${nodeId}: Attribute ${endpoint.number}/${behavior.cluster.id} changed to ${Diagnostic.json(changed)} (version ${version})`, + ); + break; + } + case "event": { + const { behavior, event, number, timestamp, priority, payload } = change; + if (!ClusterBehavior.is(behavior)) { + break; + } + console.log( + `Node ${nodeId}: Event ${endpoint.number}/${behavior.cluster.id}/${event.propertyName} (#${number}, priority ${priority}, at ${timestamp}) triggered with ${Diagnostic.json(payload)}`, + ); + break; + } + case "delete": { + console.log(`Node ${nodeId}: Endpoint ${endpoint.number} removed`); + break; + } + } + }); + + // Log each commissioned peer's connection-state transitions, torn down when the peer is removed. + // peers.added/deleted fire for every node in the container (including transient discovery and group nodes), + // so key the handlers by node to remove them on cull and skip nodes without a peer address. + const connectionHandlers = new Map void>(); + const watchConnection = (peer: ClientNode) => { + if (connectionHandlers.has(peer)) { + return; + } + const handler = (state: NodeConnectionState) => { + const nodeId = peer.peerAddress?.nodeId; + if (nodeId !== undefined) { + console.log(`Node ${nodeId} ${connectionStateLabel(state)}`); + } + }; + connectionHandlers.set(peer, handler); + peer.lifecycle.connectionStateChanged.on(handler); + }; + const unwatchConnection = (peer: ClientNode) => { + const handler = connectionHandlers.get(peer); + if (handler !== undefined) { + peer.lifecycle.connectionStateChanged.off(handler); + connectionHandlers.delete(peer); + } + }; + for (const peer of node.peers.commissioned) { + watchConnection(peer); + } + observers.on(node.peers.added, watchConnection); + observers.on(node.peers.deleted, unwatchConnection); +} diff --git a/packages/protocol/src/peer/Peer.ts b/packages/protocol/src/peer/Peer.ts index b7ee9113c3..7d767d2608 100644 --- a/packages/protocol/src/peer/Peer.ts +++ b/packages/protocol/src/peer/Peer.ts @@ -31,6 +31,7 @@ import { Lifetime, Logger, Millis, + Observable, ObserverGroup, QuietObservable, Seconds, @@ -85,6 +86,10 @@ export class Peer { #observers = new ObserverGroup(); #exchangeProvider?: ExchangeProvider; #updated = AsyncObservable<[peer: Peer]>(); + // Emitted from the MRP retransmission path; swallow observer throws so a bad listener can't break CASE establishment. + #establishmentUnresponsive = Observable<[]>(error => + logger.warn("Unhandled error in establishmentUnresponsive observer:", error), + ); #addressMonitor?: PeerAddressMonitor; constructor(descriptor: PeerDescriptor, context: Peer.Context) { @@ -176,6 +181,15 @@ export class Peer { return this.#updated; } + /** + * Emits when a CASE establishment attempt has retransmitted past the MRP budget without a response, indicating + * the peer is likely unresponsive. The latch re-arms per attempt (and per handshake message), so a later stall + * can emit again. Retransmission itself is unchanged; this is only a signal. + */ + get establishmentUnresponsive(): Observable<[]> { + return this.#establishmentUnresponsive; + } + get lifetime() { return this.#lifetime; } diff --git a/packages/protocol/src/peer/PeerConnection.ts b/packages/protocol/src/peer/PeerConnection.ts index cb2610d25e..48bad62e6d 100644 --- a/packages/protocol/src/peer/PeerConnection.ts +++ b/packages/protocol/src/peer/PeerConnection.ts @@ -7,6 +7,7 @@ import type { Message } from "#codec/MessageCodec.js"; import { DiscoveryData } from "#common/Scanner.js"; import type { ExchangeManager } from "#protocol/ExchangeManager.js"; +import { MRP } from "#protocol/MRP.js"; import { ChannelStatusResponseError } from "#securechannel/SecureChannelMessenger.js"; import { CaseClient } from "#session/case/CaseClient.js"; import type { NodeSession } from "#session/NodeSession.js"; @@ -531,6 +532,9 @@ export async function PeerConnection( unsecuredSession, network, peerAdditionalMrpDelay, + SECURE_CHANNEL_PROTOCOL_ID, + undefined, + PeerConnection.establishmentUnresponsiveDetector(() => peer.establishmentUnresponsive.emit()), ); info( @@ -800,6 +804,25 @@ export namespace PeerConnection { handleError?: (error: Error) => Duration | void; } + /** + * Observes an establishment exchange's per-(re)transmission count and invokes {@link onUnresponsive} once the + * peer has failed to ack within the MRP budget ({@link MRP.MAX_TRANSMISSIONS}). The latch re-arms whenever the + * count returns to zero, so a fresh message cycle or attempt can signal again. + */ + export function establishmentUnresponsiveDetector( + onUnresponsive: () => void, + ): (retransmissionCount: number) => void { + let signaled = false; + return retransmissionCount => { + if (retransmissionCount === 0) { + signaled = false; + } else if (retransmissionCount > MRP.MAX_TRANSMISSIONS && !signaled) { + signaled = true; + onUnresponsive(); + } + }; + } + export function createExchange( peer: Peer, exchanges: ExchangeManager, @@ -808,6 +831,7 @@ export namespace PeerConnection { peerAdditionalMrpDelay?: Duration, protocol = SECURE_CHANNEL_PROTOCOL_ID, addressOverride?: ServerAddressUdp, + onRetransmit?: (retransmissionCount: number) => void, ) { return exchanges.initiateExchangeForSession(session, protocol, { onSend, @@ -824,6 +848,7 @@ export namespace PeerConnection { // beneficial to *not* do so when the network is congested peer.service.status.isReachable = false; } + onRetransmit?.(retransmission); } function onReceive() { diff --git a/packages/protocol/test/peer/PeerEstablishmentProgressTest.ts b/packages/protocol/test/peer/PeerEstablishmentProgressTest.ts new file mode 100644 index 0000000000..f0ce0f82ac --- /dev/null +++ b/packages/protocol/test/peer/PeerEstablishmentProgressTest.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PeerConnection } from "#peer/PeerConnection.js"; +import { MessageExchange } from "#protocol/MessageExchange.js"; +import { MRP } from "#protocol/MRP.js"; +import { ProtocolMocks } from "#protocol/ProtocolMocks.js"; +import { SessionParameters } from "#session/SessionParameters.js"; +import { Abort, Bytes, Millis, Observable, Seconds } from "@matter/general"; +import { SECURE_CHANNEL_PROTOCOL_ID } from "@matter/types"; + +/** + * The establishment-unresponsive signal fires once per CASE establishment attempt when the current + * (re)transmission has retransmitted past the MRP budget ({@link MRP.MAX_TRANSMISSIONS}), meaning the peer is likely + * unresponsive. The underlying unbounded retransmission behavior is unchanged; this only observes it. + */ +describe("PeerConnection establishment progress", () => { + describe("establishmentUnresponsiveDetector", () => { + it("does not fire while the retransmission count stays within the MRP budget", () => { + let fired = 0; + const detect = PeerConnection.establishmentUnresponsiveDetector(() => fired++); + + for (let count = 0; count <= MRP.MAX_TRANSMISSIONS; count++) { + detect(count); + expect(fired).equals(0); + } + }); + + it("fires exactly once when the count first exceeds the budget and not again while climbing", () => { + let fired = 0; + const detect = PeerConnection.establishmentUnresponsiveDetector(() => fired++); + + for (let count = 0; count <= MRP.MAX_TRANSMISSIONS; count++) { + detect(count); + } + expect(fired).equals(0); + + detect(MRP.MAX_TRANSMISSIONS + 1); + expect(fired).equals(1); + + detect(MRP.MAX_TRANSMISSIONS + 2); + detect(MRP.MAX_TRANSMISSIONS + 3); + expect(fired).equals(1); + }); + + it("re-arms when the counter resets to zero for a fresh attempt", () => { + let fired = 0; + const detect = PeerConnection.establishmentUnresponsiveDetector(() => fired++); + + detect(MRP.MAX_TRANSMISSIONS + 1); + expect(fired).equals(1); + + detect(0); + for (let count = 1; count <= MRP.MAX_TRANSMISSIONS; count++) { + detect(count); + expect(fired).equals(1); + } + + detect(MRP.MAX_TRANSMISSIONS + 1); + expect(fired).equals(2); + }); + + it("keeps an independent latch per detector instance", () => { + let firedA = 0; + let firedB = 0; + const detectA = PeerConnection.establishmentUnresponsiveDetector(() => firedA++); + const detectB = PeerConnection.establishmentUnresponsiveDetector(() => firedB++); + + detectA(MRP.MAX_TRANSMISSIONS + 1); + expect(firedA).equals(1); + expect(firedB).equals(0); + + detectB(MRP.MAX_TRANSMISSIONS + 1); + expect(firedA).equals(1); + expect(firedB).equals(1); + }); + }); + + describe("driven by a real never-acked exchange", () => { + before(() => MockTime.enable()); + afterEach(() => MockTime.reset()); + + function makeExchange(onSend: MessageExchange.SendNotifier) { + const channel = new ProtocolMocks.NetworkChannel({ index: 1 }); + channel.isReliable = false; // engage MRP so the send retransmits while unacked + const session = new ProtocolMocks.NodeSession({ channel }); + return MessageExchange.initiate( + { + session, + localSessionParameters: SessionParameters(SessionParameters.defaults), + localAdditionalMrpDelay: Millis(0), + localFixedMrpBackoff: Millis(0), + async peerLost() {}, + retry() {}, + }, + 1, + SECURE_CHANNEL_PROTOCOL_ID, + { onSend }, + ); + } + + it("fires once after retransmitting past the MRP budget and never while within it", async () => { + MockTime.reset(); + + let fired = 0; + const unresponsive = Observable<[]>(); + unresponsive.on(() => { + fired++; + }); + const detect = PeerConnection.establishmentUnresponsiveDetector(() => unresponsive.emit()); + + const counts = new Array(); + const exchange = makeExchange((_message, retransmission) => { + counts.push(retransmission); + detect(retransmission); + }); + + using abort = new Abort(); + // Unbounded retransmission mirrors PeerConnection's `maxInitialRetransmissions: Infinity`. + const sendPromise = exchange.send(1, Bytes.empty, { + requiresAck: true, + maxRetransmissions: Infinity, + abort, + }); + + for (let i = 0; i < 20; i++) { + await MockTime.advance(Seconds(60)); + await MockTime.yield3(); + + const maxCount = Math.max(...counts, 0); + if (maxCount <= MRP.MAX_TRANSMISSIONS) { + expect(fired).equals(0); + } else { + break; + } + } + + expect(Math.max(...counts, 0)).greaterThan(MRP.MAX_TRANSMISSIONS); + expect(fired).equals(1); + + abort(); + await sendPromise.catch(() => {}); + await exchange.destroy(); + }); + }); +}); diff --git a/packages/types/src/cluster/ClusterHelper.ts b/packages/types/src/cluster/ClusterHelper.ts index 6c2fc8e1b4..3ded77bacb 100644 --- a/packages/types/src/cluster/ClusterHelper.ts +++ b/packages/types/src/cluster/ClusterHelper.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { Diagnostic } from "@matter/general"; -import { AttributeModel, ClusterModel, CommandModel, EventModel, Matter } from "@matter/model"; +import { AttributeModel, ClusterModel, CommandModel, EventModel, Matter, MatterModel } from "@matter/model"; import { ClusterId } from "../datatype/ClusterId.js"; import { EndpointNumber } from "../datatype/EndpointNumber.js"; import { NodeId } from "../datatype/NodeId.js"; @@ -14,14 +14,15 @@ function toHex(value: number | bigint | undefined) { return value === undefined ? "*" : `0x${value.toString(16)}`; } -export function getClusterNameById(clusterId: ClusterId): string { - return Matter.clusters(clusterId)?.name ?? `Unknown cluster ${Diagnostic.hex(clusterId)}`; +export function getClusterNameById(clusterId: ClusterId, matter: MatterModel = Matter): string { + return matter.clusters(clusterId)?.name ?? `Unknown cluster ${Diagnostic.hex(clusterId)}`; } function resolveEndpointClusterName( nodeId: NodeId | undefined, endpointId: EndpointNumber | undefined, clusterId: ClusterId | undefined, + matter: MatterModel, ) { let elementName = nodeId === undefined ? "" : `${toHex(nodeId)}/`; if (endpointId === undefined) { @@ -33,7 +34,7 @@ function resolveEndpointClusterName( if (clusterId === undefined) { return `${elementName}/*`; } - const name = Matter.clusters(clusterId)?.name; + const name = matter.clusters(clusterId)?.name; if (name === undefined) { return `${elementName}/unknown(${toHex(clusterId)})`; } @@ -52,12 +53,15 @@ function resolveElementName(cluster: ClusterModel | undefined, tag: string, elem return undefined; } -export function resolveAttributeName({ nodeId, endpointId, clusterId, attributeId }: AttributePath) { - const endpointClusterName = resolveEndpointClusterName(nodeId, endpointId, clusterId); +export function resolveAttributeName( + { nodeId, endpointId, clusterId, attributeId }: AttributePath, + matter: MatterModel = Matter, +) { + const endpointClusterName = resolveEndpointClusterName(nodeId, endpointId, clusterId, matter); if (endpointId === undefined || clusterId === undefined || attributeId === undefined) { return `${endpointClusterName}/${toHex(attributeId)}`; } - const cluster = Matter.clusters(clusterId); + const cluster = matter.clusters(clusterId); const name = resolveElementName(cluster, AttributeModel.Tag, attributeId); if (name === undefined) { return `${endpointClusterName}/unknown(${toHex(attributeId)})`; @@ -65,13 +69,16 @@ export function resolveAttributeName({ nodeId, endpointId, clusterId, attributeI return `${endpointClusterName}/${name}(${toHex(attributeId)})`; } -export function resolveEventName({ nodeId, endpointId, clusterId, eventId, isUrgent }: EventPath) { +export function resolveEventName( + { nodeId, endpointId, clusterId, eventId, isUrgent }: EventPath, + matter: MatterModel = Matter, +) { const isUrgentStr = isUrgent ? "!" : ""; - const endpointClusterName = resolveEndpointClusterName(nodeId, endpointId, clusterId); + const endpointClusterName = resolveEndpointClusterName(nodeId, endpointId, clusterId, matter); if (endpointId === undefined || clusterId === undefined || eventId === undefined) { return `${isUrgentStr}${endpointClusterName}/${toHex(eventId)}`; } - const cluster = Matter.clusters(clusterId); + const cluster = matter.clusters(clusterId); const name = resolveElementName(cluster, EventModel.Tag, eventId); if (name === undefined) { return `${isUrgentStr}${endpointClusterName}/unknown(${toHex(eventId)})`; @@ -79,15 +86,72 @@ export function resolveEventName({ nodeId, endpointId, clusterId, eventId, isUrg return `${isUrgentStr}${endpointClusterName}/${name}(${toHex(eventId)})`; } -export function resolveCommandName({ endpointId, clusterId, commandId }: CommandPath) { - const endpointClusterName = resolveEndpointClusterName(undefined, endpointId, clusterId); +export function resolveCommandName({ endpointId, clusterId, commandId }: CommandPath, matter: MatterModel = Matter) { + const endpointClusterName = resolveEndpointClusterName(undefined, endpointId, clusterId, matter); if (endpointId === undefined || clusterId === undefined || commandId === undefined) { return `${endpointClusterName}/${toHex(commandId)}`; } - const cluster = Matter.clusters(clusterId); + const cluster = matter.clusters(clusterId); const name = resolveElementName(cluster, CommandModel.Tag, commandId); if (name === undefined) { return `${endpointClusterName}/unknown(${toHex(commandId)})`; } return `${endpointClusterName}/${name}(${toHex(commandId)})`; } + +/** + * Bidirectional name↔ID resolution for cluster attributes, events and commands. + * + * Each function is scoped to a cluster within a {@link MatterModel}, defaulting to the global {@link Matter} model; + * pass a node's model (`node.matter`) so custom/vendor clusters resolve. Lookups accept either the camelCase name + * or the numeric ID and return `undefined` when unknown. + */ +export namespace ClusterLookup { + export function attributeId( + clusterId: ClusterId, + nameOrId: string | number, + matter: MatterModel = Matter, + ): number | undefined { + return matter.clusters(clusterId)?.attributes(nameOrId)?.id; + } + + export function attributeName( + clusterId: ClusterId, + nameOrId: string | number, + matter: MatterModel = Matter, + ): string | undefined { + return matter.clusters(clusterId)?.attributes(nameOrId)?.propertyName; + } + + export function eventId( + clusterId: ClusterId, + nameOrId: string | number, + matter: MatterModel = Matter, + ): number | undefined { + return matter.clusters(clusterId)?.events(nameOrId)?.id; + } + + export function eventName( + clusterId: ClusterId, + nameOrId: string | number, + matter: MatterModel = Matter, + ): string | undefined { + return matter.clusters(clusterId)?.events(nameOrId)?.propertyName; + } + + export function commandId( + clusterId: ClusterId, + nameOrId: string | number, + matter: MatterModel = Matter, + ): number | undefined { + return matter.clusters(clusterId)?.commands(nameOrId)?.id; + } + + export function commandName( + clusterId: ClusterId, + nameOrId: string | number, + matter: MatterModel = Matter, + ): string | undefined { + return matter.clusters(clusterId)?.commands(nameOrId)?.propertyName; + } +} diff --git a/packages/types/test/cluster/ClusterNamingTest.ts b/packages/types/test/cluster/ClusterNamingTest.ts new file mode 100644 index 0000000000..f8b8a89a57 --- /dev/null +++ b/packages/types/test/cluster/ClusterNamingTest.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ClusterLookup } from "#cluster/ClusterHelper.js"; +import { BooleanState } from "#clusters/boolean-state.js"; +import { OnOff } from "#clusters/on-off.js"; +import { ClusterId } from "#datatype/ClusterId.js"; +import { ClusterModel, MatterModel } from "@matter/model"; + +const { attributeId, attributeName, commandId, commandName, eventId, eventName } = ClusterLookup; + +describe("ClusterLookup", () => { + const onOffId = OnOff.Cluster.id; + const booleanStateId = BooleanState.Cluster.id; + const unknownClusterId = ClusterId(0xffff, false); + + // Absent from the global Matter model, so resolving it only succeeds if the custom model argument is honored. + const customClusterId = ClusterId(0xfffd, false); + const customMatter = new MatterModel({ + name: "CustomMatter", + children: [ + new ClusterModel({ + id: customClusterId, + name: "CustomCluster", + children: [{ tag: "attribute", id: 0x0000, name: "customAttr" }], + }), + ], + }); + + describe("attributeId / attributeName", () => { + it("resolves a known attribute name to its id", () => { + expect(attributeId(onOffId, "onOff")).equal(0x0000); + }); + + it("resolves a known attribute id to its name", () => { + expect(attributeName(onOffId, 0x0000)).equal("onOff"); + }); + + it("returns undefined for an unknown attribute name", () => { + expect(attributeId(onOffId, "doesNotExist")).undefined; + }); + + it("returns undefined for an unknown attribute id", () => { + expect(attributeName(onOffId, 0x1234)).undefined; + }); + + it("returns undefined for an unknown cluster", () => { + expect(attributeId(unknownClusterId, "onOff")).undefined; + expect(attributeName(unknownClusterId, 0x0000)).undefined; + }); + + it("resolves against an explicit, non-global MatterModel", () => { + expect(attributeId(customClusterId, "customAttr")).undefined; + expect(attributeId(customClusterId, "customAttr", customMatter)).equal(0x0000); + expect(attributeName(customClusterId, 0x0000, customMatter)).equal("customAttr"); + }); + }); + + describe("commandId / commandName", () => { + it("resolves a known command name to its id", () => { + expect(commandId(onOffId, "off")).equal(0x00); + expect(commandId(onOffId, "on")).equal(0x01); + expect(commandId(onOffId, "toggle")).equal(0x02); + }); + + it("resolves a known command id to its name", () => { + expect(commandName(onOffId, 0x00)).equal("off"); + }); + + it("returns undefined for an unknown command", () => { + expect(commandId(onOffId, "doesNotExist")).undefined; + expect(commandName(onOffId, 0xff)).undefined; + }); + }); + + describe("eventId / eventName", () => { + it("round-trips a known event name to its id and back", () => { + const id = eventId(booleanStateId, "stateChange"); + expect(id).not.undefined; + expect(eventName(booleanStateId, id!)).equal("stateChange"); + }); + + it("returns undefined for an unknown event", () => { + expect(eventId(booleanStateId, "doesNotExist")).undefined; + expect(eventName(booleanStateId, 0xff)).undefined; + }); + + it("returns undefined for an unknown cluster", () => { + expect(eventId(unknownClusterId, "stateChange")).undefined; + }); + }); +});