@matter-server/ws-controller 0.6.8 → 0.7.0-alpha.0-20260516-8ee9631
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/controller/ControllerCommandHandler.d.ts +12 -1
- package/dist/esm/controller/ControllerCommandHandler.d.ts.map +1 -1
- package/dist/esm/controller/ControllerCommandHandler.js +115 -34
- package/dist/esm/controller/ControllerCommandHandler.js.map +1 -1
- package/dist/esm/controller/MatterController.d.ts +8 -3
- package/dist/esm/controller/MatterController.d.ts.map +1 -1
- package/dist/esm/controller/MatterController.js +46 -2
- package/dist/esm/controller/MatterController.js.map +1 -1
- package/dist/esm/controller/Nodes.d.ts +7 -61
- package/dist/esm/controller/Nodes.d.ts.map +1 -1
- package/dist/esm/controller/Nodes.js +13 -77
- package/dist/esm/controller/Nodes.js.map +1 -1
- package/dist/esm/controller/WebRtcCallbackBridge.d.ts +9 -0
- package/dist/esm/controller/WebRtcCallbackBridge.d.ts.map +1 -0
- package/dist/esm/controller/WebRtcCallbackBridge.js +75 -0
- package/dist/esm/controller/WebRtcCallbackBridge.js.map +6 -0
- package/dist/esm/controller/behaviors/WebRtcTransportRequestorServer.d.ts +35 -0
- package/dist/esm/controller/behaviors/WebRtcTransportRequestorServer.d.ts.map +1 -0
- package/dist/esm/controller/behaviors/WebRtcTransportRequestorServer.js +123 -0
- package/dist/esm/controller/behaviors/WebRtcTransportRequestorServer.js.map +6 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/server/ConfigStorage.d.ts +2 -0
- package/dist/esm/server/ConfigStorage.d.ts.map +1 -1
- package/dist/esm/server/ConfigStorage.js +18 -0
- package/dist/esm/server/ConfigStorage.js.map +1 -1
- package/dist/esm/server/WebSocketControllerHandler.d.ts.map +1 -1
- package/dist/esm/server/WebSocketControllerHandler.js +44 -0
- package/dist/esm/server/WebSocketControllerHandler.js.map +1 -1
- package/package.json +22 -24
- package/src/controller/ControllerCommandHandler.ts +141 -54
- package/src/controller/MatterController.ts +56 -3
- package/src/controller/Nodes.ts +13 -78
- package/src/controller/WebRtcCallbackBridge.ts +79 -0
- package/src/controller/behaviors/WebRtcTransportRequestorServer.ts +149 -0
- package/src/index.ts +1 -0
- package/src/server/ConfigStorage.ts +20 -0
- package/src/server/WebSocketControllerHandler.ts +57 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { WebRtcCallbackData } from "@matter-server/ws-client";
|
|
7
8
|
import {
|
|
8
9
|
Abort,
|
|
9
10
|
AsyncObservable,
|
|
@@ -21,7 +22,6 @@ import {
|
|
|
21
22
|
Observable,
|
|
22
23
|
Seconds,
|
|
23
24
|
ServerAddress,
|
|
24
|
-
ServerAddressUdp,
|
|
25
25
|
SoftwareUpdateInfo,
|
|
26
26
|
SoftwareUpdateManager,
|
|
27
27
|
Time,
|
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
GeneralCommissioning,
|
|
38
38
|
OperationalCredentials,
|
|
39
39
|
} from "@matter/main/clusters";
|
|
40
|
+
import { WebRtcTransportDefinitions } from "@matter/main/clusters/web-rtc-transport-definitions";
|
|
41
|
+
import { WebRtcTransportProvider } from "@matter/main/clusters/web-rtc-transport-provider";
|
|
40
42
|
import {
|
|
41
43
|
DecodedAttributeReportValue,
|
|
42
44
|
DecodedEventReportValue,
|
|
@@ -44,7 +46,6 @@ import {
|
|
|
44
46
|
PeerAddress,
|
|
45
47
|
Read,
|
|
46
48
|
Specifier,
|
|
47
|
-
SupportedTransportsSchema,
|
|
48
49
|
PeerSet,
|
|
49
50
|
} from "@matter/main/protocol";
|
|
50
51
|
import {
|
|
@@ -61,6 +62,8 @@ import {
|
|
|
61
62
|
StatusResponseError,
|
|
62
63
|
VendorId,
|
|
63
64
|
} from "@matter/main/types";
|
|
65
|
+
import { Endpoint } from "@matter/node";
|
|
66
|
+
import { CameraControllerDevice } from "@matter/node/devices/camera-controller";
|
|
64
67
|
import { CommissioningController, NodeCommissioningOptions } from "@project-chip/matter.js";
|
|
65
68
|
import { NodeStates } from "@project-chip/matter.js/device";
|
|
66
69
|
import { ClusterMap, ClusterMapEntry, GlobalAttributes } from "../model/ModelMapper.js";
|
|
@@ -98,12 +101,14 @@ import {
|
|
|
98
101
|
} from "../types/WebSocketMessageTypes.js";
|
|
99
102
|
import { formatNodeId } from "../util/formatNodeId.js";
|
|
100
103
|
import { pingIp } from "../util/network.js";
|
|
104
|
+
import { WebRtcTransportRequestorServer } from "./behaviors/WebRtcTransportRequestorServer.js";
|
|
101
105
|
import { CustomClusterPoller } from "./CustomClusterPoller.js";
|
|
102
106
|
import { Nodes } from "./Nodes.js";
|
|
107
|
+
import { attachWebRtcCallbackBridge } from "./WebRtcCallbackBridge.js";
|
|
103
108
|
|
|
104
109
|
const logger = Logger.get("ControllerCommandHandler");
|
|
105
110
|
|
|
106
|
-
/**
|
|
111
|
+
/** Grace period after leaving Connected before a node is declared unavailable. */
|
|
107
112
|
const RECONNECT_TIMEOUT = Minutes(3);
|
|
108
113
|
|
|
109
114
|
/**
|
|
@@ -173,6 +178,7 @@ export class ControllerCommandHandler {
|
|
|
173
178
|
nodeDecommissioned: new Observable<[nodeId: NodeId]>(),
|
|
174
179
|
nodeEndpointAdded: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(),
|
|
175
180
|
nodeEndpointRemoved: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(),
|
|
181
|
+
webRtcCallback: new Observable<[WebRtcCallbackData]>(),
|
|
176
182
|
};
|
|
177
183
|
#peers?: PeerSet;
|
|
178
184
|
|
|
@@ -240,6 +246,7 @@ export class ControllerCommandHandler {
|
|
|
240
246
|
}
|
|
241
247
|
|
|
242
248
|
await this.events.started.emit();
|
|
249
|
+
await this.#setupWebRtcCallbackBridge();
|
|
243
250
|
}
|
|
244
251
|
|
|
245
252
|
/**
|
|
@@ -284,6 +291,88 @@ export class ControllerCommandHandler {
|
|
|
284
291
|
}
|
|
285
292
|
}
|
|
286
293
|
|
|
294
|
+
async #setupWebRtcCallbackBridge() {
|
|
295
|
+
try {
|
|
296
|
+
await this.#cameraControllerEndpoint().act(agent => {
|
|
297
|
+
attachWebRtcCallbackBridge(agent.get(WebRtcTransportRequestorServer).events, data =>
|
|
298
|
+
this.events.webRtcCallback.emit(data),
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
logger.info("WebRTC callback bridge wired");
|
|
302
|
+
} catch (error) {
|
|
303
|
+
logger.warn("Failed to setup WebRTC callback bridge:", error);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#cameraControllerEndpoint(): Endpoint<typeof CameraControllerDevice> {
|
|
308
|
+
return this.#controller.node.endpoints.for("camera-controller") as Endpoint<typeof CameraControllerDevice>;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** `originatingEndpointId` is server-injected; any client-supplied value in `payload` is overwritten. */
|
|
312
|
+
async sendWebRtcProviderCommand(args: {
|
|
313
|
+
nodeId: NodeId;
|
|
314
|
+
endpointId: EndpointNumber;
|
|
315
|
+
commandName: "ProvideOffer" | "SolicitOffer";
|
|
316
|
+
payload: Record<string, unknown>;
|
|
317
|
+
}): Promise<WebRtcTransportProvider.ProvideOfferResponse | WebRtcTransportProvider.SolicitOfferResponse> {
|
|
318
|
+
const { nodeId, endpointId, commandName, payload } = args;
|
|
319
|
+
|
|
320
|
+
if (commandName !== "ProvideOffer" && commandName !== "SolicitOffer") {
|
|
321
|
+
throw ServerError.invalidArguments(
|
|
322
|
+
`Unsupported WebRTC provider command "${commandName}"; expected ProvideOffer or SolicitOffer`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const requestorEndpoint = this.#cameraControllerEndpoint();
|
|
327
|
+
const originatingEndpointId = EndpointNumber(requestorEndpoint.number);
|
|
328
|
+
const fabricIndex = this.#controller.fabric.fabricIndex;
|
|
329
|
+
|
|
330
|
+
const node = this.#nodes.get(nodeId);
|
|
331
|
+
|
|
332
|
+
const fields: Record<string, unknown> = {
|
|
333
|
+
...payload,
|
|
334
|
+
originatingEndpointId,
|
|
335
|
+
};
|
|
336
|
+
const command = commandName === "ProvideOffer" ? "provideOffer" : "solicitOffer";
|
|
337
|
+
|
|
338
|
+
const response = (await this.#invokeCommand(node.node, {
|
|
339
|
+
endpoint: endpointId,
|
|
340
|
+
cluster: WebRtcTransportProvider,
|
|
341
|
+
command,
|
|
342
|
+
fields,
|
|
343
|
+
})) as WebRtcTransportProvider.ProvideOfferResponse | WebRtcTransportProvider.SolicitOfferResponse | undefined;
|
|
344
|
+
|
|
345
|
+
if (response === undefined || typeof response.webRtcSessionId !== "number") {
|
|
346
|
+
throw ServerError.sdkStackError(
|
|
347
|
+
`${commandName} did not return a WebRTCSessionID for node ${this.formatNode(nodeId)}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const streamUsage = payload.streamUsage as WebRtcTransportDefinitions.WebRtcSession["streamUsage"];
|
|
352
|
+
const metadataEnabled = (payload.metadataEnabled as boolean | undefined) ?? false;
|
|
353
|
+
const videoStreams = response.videoStreamId != null ? [response.videoStreamId] : new Array<number>();
|
|
354
|
+
const audioStreams = response.audioStreamId != null ? [response.audioStreamId] : new Array<number>();
|
|
355
|
+
const session: WebRtcTransportDefinitions.WebRtcSession = {
|
|
356
|
+
id: response.webRtcSessionId,
|
|
357
|
+
peerNodeId: nodeId,
|
|
358
|
+
peerEndpointId: endpointId,
|
|
359
|
+
streamUsage,
|
|
360
|
+
metadataEnabled,
|
|
361
|
+
videoStreams,
|
|
362
|
+
audioStreams,
|
|
363
|
+
fabricIndex,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
logger.info(
|
|
367
|
+
`upserting WebRTC session id=${session.id} peerNodeId=${nodeId} peerEndpointId=${endpointId} fabricIndex=${fabricIndex} streamUsage=${streamUsage} originatingEndpointId=${originatingEndpointId}`,
|
|
368
|
+
);
|
|
369
|
+
await requestorEndpoint.act(agent => {
|
|
370
|
+
agent.get(WebRtcTransportRequestorServer).upsertSession(session);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
return response;
|
|
374
|
+
}
|
|
375
|
+
|
|
287
376
|
async close() {
|
|
288
377
|
for (const timer of this.#reconnectTimers.values()) {
|
|
289
378
|
timer.stop();
|
|
@@ -300,16 +389,12 @@ export class ControllerCommandHandler {
|
|
|
300
389
|
const node = await this.#controller.getNode(nodeId);
|
|
301
390
|
const attributeCache = this.#nodes.attributeCache;
|
|
302
391
|
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
// a subscription batch. When the batch ends (connectionAlive), emit a full node_updated.
|
|
392
|
+
// Defer the full node_updated until the subscription batch ends (connectionAlive)
|
|
393
|
+
// so consumers see one update per batch rather than one per attribute.
|
|
306
394
|
let basicInfoChangedInBatch = false;
|
|
307
395
|
node.events.attributeChanged.on(data => {
|
|
308
|
-
// Update the attribute cache with the new value in WebSocket format
|
|
309
396
|
attributeCache.updateAttribute(nodeId, data);
|
|
310
|
-
// Then emit the event for listeners
|
|
311
397
|
this.events.attributeChanged.emit(nodeId, data);
|
|
312
|
-
// Mark for full node_updated if any BasicInformation cluster attribute changed
|
|
313
398
|
if (FULL_UPDATE_CLUSTER_IDS.has(data.path.clusterId)) {
|
|
314
399
|
basicInfoChangedInBatch = true;
|
|
315
400
|
}
|
|
@@ -323,7 +408,6 @@ export class ControllerCommandHandler {
|
|
|
323
408
|
});
|
|
324
409
|
node.events.eventTriggered.on(data => this.events.eventChanged.emit(nodeId, data));
|
|
325
410
|
node.events.stateChanged.on(state => {
|
|
326
|
-
// Only refresh cache on Connected state
|
|
327
411
|
if (state === NodeStates.Connected) {
|
|
328
412
|
attributeCache.update(node);
|
|
329
413
|
const attributes = attributeCache.get(nodeId);
|
|
@@ -332,35 +416,35 @@ export class ControllerCommandHandler {
|
|
|
332
416
|
}
|
|
333
417
|
}
|
|
334
418
|
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const timer = Time.getTimer(`reconnect-timeout-${nodeId}`, RECONNECT_TIMEOUT, () => {
|
|
339
|
-
this.#reconnectTimers.delete(nodeId);
|
|
340
|
-
if (this.#nodes.forceUnavailable(nodeId)) {
|
|
341
|
-
logger.info(
|
|
342
|
-
`Node ${this.formatNode(nodeId)} still reconnecting after timeout, marking unavailable`,
|
|
343
|
-
);
|
|
344
|
-
this.events.nodeAvailabilityChanged.emit(nodeId, false);
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
timer.utility = true;
|
|
348
|
-
timer.start();
|
|
349
|
-
this.#reconnectTimers.set(nodeId, timer);
|
|
350
|
-
}
|
|
351
|
-
} else {
|
|
352
|
-
// Any non-Reconnecting state clears the timer
|
|
419
|
+
// Arm on Connected->Reconnecting only; keep running across later
|
|
420
|
+
// non-Connected states; cancel on return to Connected.
|
|
421
|
+
if (state === NodeStates.Connected) {
|
|
353
422
|
this.#reconnectTimers.get(nodeId)?.stop();
|
|
354
423
|
this.#reconnectTimers.delete(nodeId);
|
|
424
|
+
} else if (
|
|
425
|
+
state === NodeStates.Reconnecting &&
|
|
426
|
+
!this.#reconnectTimers.has(nodeId) &&
|
|
427
|
+
this.#nodes.isAvailable(nodeId)
|
|
428
|
+
) {
|
|
429
|
+
const timer = Time.getTimer(`reconnect-timeout-${nodeId}`, RECONNECT_TIMEOUT, () => {
|
|
430
|
+
this.#reconnectTimers.delete(nodeId);
|
|
431
|
+
if (this.#nodes.forceUnavailable(nodeId)) {
|
|
432
|
+
logger.info(
|
|
433
|
+
`Node ${this.formatNode(nodeId)} offline grace period expired, marking unavailable`,
|
|
434
|
+
);
|
|
435
|
+
this.events.nodeAvailabilityChanged.emit(nodeId, false);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
timer.utility = true;
|
|
439
|
+
timer.start();
|
|
440
|
+
this.#reconnectTimers.set(nodeId, timer);
|
|
355
441
|
}
|
|
356
442
|
|
|
357
|
-
|
|
358
|
-
const result = this.#nodes.processStateChange(nodeId, state);
|
|
443
|
+
const debouncePending = this.#reconnectTimers.has(nodeId);
|
|
444
|
+
const result = this.#nodes.processStateChange(nodeId, state, debouncePending);
|
|
359
445
|
|
|
360
|
-
// Emit state changed event
|
|
361
446
|
this.events.nodeStateChanged.emit(nodeId, state);
|
|
362
447
|
|
|
363
|
-
// Emit availability changed if it actually changed
|
|
364
448
|
if (result.availabilityChanged) {
|
|
365
449
|
logger.info(
|
|
366
450
|
`Node ${this.formatNode(nodeId)} availability changed to ${result.available} (state: ${NodeStates[state]})`,
|
|
@@ -369,13 +453,10 @@ export class ControllerCommandHandler {
|
|
|
369
453
|
}
|
|
370
454
|
});
|
|
371
455
|
node.events.structureChanged.on(() => {
|
|
372
|
-
// Structure changed means endpoints may have been added/removed, refresh cache
|
|
373
456
|
if (node.isConnected) {
|
|
374
457
|
attributeCache.update(node);
|
|
375
458
|
}
|
|
376
459
|
basicInfoChangedInBatch = false;
|
|
377
|
-
// Emit node_updated first so consumers see the new endpoint in node.endpoints,
|
|
378
|
-
// then drain any endpoint_added events queued since the previous structure change.
|
|
379
460
|
this.events.nodeStructureChanged.emit(nodeId);
|
|
380
461
|
for (const endpointId of this.#nodes.drainPendingEndpointAdds(nodeId)) {
|
|
381
462
|
this.events.nodeEndpointAdded.emit(nodeId, endpointId);
|
|
@@ -388,15 +469,12 @@ export class ControllerCommandHandler {
|
|
|
388
469
|
node.events.nodeEndpointAdded.on(endpointId => this.#nodes.queueEndpointAdded(nodeId, endpointId));
|
|
389
470
|
node.events.nodeEndpointRemoved.on(endpointId => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
|
|
390
471
|
|
|
391
|
-
// Store the node for direct access
|
|
392
472
|
this.#nodes.set(nodeId, node);
|
|
393
473
|
|
|
394
474
|
this.#nodes.seedState(nodeId, node.connectionState);
|
|
395
475
|
|
|
396
|
-
// Initialize attribute cache if node is already initialized
|
|
397
476
|
if (node.initialized) {
|
|
398
477
|
attributeCache.add(node);
|
|
399
|
-
// Register for custom cluster polling (e.g., Eve energy)
|
|
400
478
|
const attributes = attributeCache.get(nodeId);
|
|
401
479
|
if (attributes) {
|
|
402
480
|
this.#customClusterPoller.registerNode(this.#peerOf(nodeId), attributes);
|
|
@@ -659,7 +737,8 @@ export class ControllerCommandHandler {
|
|
|
659
737
|
|
|
660
738
|
value = convertWebSocketTagBasedToMatter(value, attributeModel, clusterEntry.model);
|
|
661
739
|
|
|
662
|
-
|
|
740
|
+
const attributeName = attributeModel.propertyName;
|
|
741
|
+
logger.info(`Writing attribute ${clusterId}.${attributeName} (${clusterId}.${attributeId}) with value`, value);
|
|
663
742
|
const { status, clusterStatus } = await this.#writeAttribute(
|
|
664
743
|
nodeId,
|
|
665
744
|
endpointId,
|
|
@@ -675,12 +754,11 @@ export class ControllerCommandHandler {
|
|
|
675
754
|
request: Invoke.ConcreteCommandRequest<C>,
|
|
676
755
|
options: Omit<Invoke.Definition, "commands"> = {},
|
|
677
756
|
) {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
)) {
|
|
757
|
+
const invoke = Invoke({
|
|
758
|
+
commands: [request],
|
|
759
|
+
...options,
|
|
760
|
+
});
|
|
761
|
+
for await (const data of node.interaction.invoke(invoke)) {
|
|
684
762
|
for (const entry of data) {
|
|
685
763
|
// We send only one command, so we only get one response back
|
|
686
764
|
switch (entry.kind) {
|
|
@@ -827,6 +905,17 @@ export class ControllerCommandHandler {
|
|
|
827
905
|
regulatoryCountryCode: "XX",
|
|
828
906
|
wifiNetwork,
|
|
829
907
|
threadNetwork,
|
|
908
|
+
onAttestationFailure: findings => {
|
|
909
|
+
let accept = true;
|
|
910
|
+
for (const f of findings) {
|
|
911
|
+
if (f.level === "error") {
|
|
912
|
+
accept = false;
|
|
913
|
+
}
|
|
914
|
+
logger.info(`Attestation finding (${f.level}):`, f.type, f.message);
|
|
915
|
+
}
|
|
916
|
+
logger.info(`Attestation ${accept ? "accepted" : "rejected"}`);
|
|
917
|
+
return accept;
|
|
918
|
+
},
|
|
830
919
|
},
|
|
831
920
|
discovery: {
|
|
832
921
|
knownAddress,
|
|
@@ -901,12 +990,12 @@ export class ControllerCommandHandler {
|
|
|
901
990
|
return [];
|
|
902
991
|
}
|
|
903
992
|
return [latestDiscovery].map(({ DT, DN, CM, D, RI, PH, PI, T, VP, deviceIdentifier, addresses, SII, SAI }) => {
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
);
|
|
993
|
+
const supportsTcpClient = T?.tcpClient ?? false;
|
|
994
|
+
const supportsTcpServer = T?.tcpServer ?? false;
|
|
907
995
|
const vendorId = VP === undefined ? -1 : VP.includes("+") ? parseInt(VP.split("+")[0]) : parseInt(VP);
|
|
908
996
|
const productId = VP === undefined ? -1 : VP.includes("+") ? parseInt(VP.split("+")[1]) : -1;
|
|
909
|
-
const
|
|
997
|
+
const firstAddress = addresses[0];
|
|
998
|
+
const port = firstAddress && ServerAddress.isIp(firstAddress) ? firstAddress.port : 0;
|
|
910
999
|
const numIPs = addresses.length;
|
|
911
1000
|
return {
|
|
912
1001
|
commissioningMode: CM,
|
|
@@ -926,7 +1015,7 @@ export class ControllerCommandHandler {
|
|
|
926
1015
|
vendorId,
|
|
927
1016
|
supportsTcpServer,
|
|
928
1017
|
supportsTcpClient,
|
|
929
|
-
addresses:
|
|
1018
|
+
addresses: addresses.filter(ServerAddress.isIp).map(({ ip }) => ip),
|
|
930
1019
|
mrpSessionIdleInterval: SII,
|
|
931
1020
|
mrpSessionActiveInterval: SAI,
|
|
932
1021
|
};
|
|
@@ -948,7 +1037,7 @@ export class ControllerCommandHandler {
|
|
|
948
1037
|
}
|
|
949
1038
|
}
|
|
950
1039
|
if (peer) {
|
|
951
|
-
const sessionIp = peer.newestSession?.channel.networkAddress?.ip;
|
|
1040
|
+
const sessionIp = peer.newestSession()?.channel.networkAddress?.ip;
|
|
952
1041
|
if (sessionIp) {
|
|
953
1042
|
addresses.add(sessionIp);
|
|
954
1043
|
}
|
|
@@ -958,9 +1047,7 @@ export class ControllerCommandHandler {
|
|
|
958
1047
|
const node = this.#nodes.get(nodeId);
|
|
959
1048
|
const commissioningAddresses = node.node.maybeStateOf(CommissioningClient)?.addresses;
|
|
960
1049
|
if (commissioningAddresses !== undefined && commissioningAddresses.length > 0) {
|
|
961
|
-
const fallbackAddresses = commissioningAddresses
|
|
962
|
-
.filter((addr): addr is ServerAddressUdp => addr.type === "udp")
|
|
963
|
-
.map(addr => addr.ip);
|
|
1050
|
+
const fallbackAddresses = commissioningAddresses.filter(ServerAddress.isIp).map(addr => addr.ip);
|
|
964
1051
|
for (const address of fallbackAddresses) {
|
|
965
1052
|
addresses.add(address);
|
|
966
1053
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { cdSigners, paaRoots, vendors } from "@matter/dcl-data/node";
|
|
7
8
|
import {
|
|
8
9
|
Bytes,
|
|
9
10
|
CommissioningClient,
|
|
@@ -18,11 +19,14 @@ import {
|
|
|
18
19
|
SoftwareUpdateManager,
|
|
19
20
|
Timestamp,
|
|
20
21
|
} from "@matter/main";
|
|
21
|
-
import { VendorInfo } from "@matter/main/protocol";
|
|
22
|
+
import { VendorInfo, DclCertificateService, DclVendorInfoService } from "@matter/main/protocol";
|
|
22
23
|
import { VendorId } from "@matter/main/types";
|
|
24
|
+
import { Endpoint } from "@matter/node";
|
|
25
|
+
import { CameraControllerDevice } from "@matter/node/devices/camera-controller";
|
|
23
26
|
import { CommissioningController } from "@project-chip/matter.js";
|
|
24
27
|
import { Readable } from "node:stream";
|
|
25
28
|
import { ConfigStorage } from "../server/ConfigStorage.js";
|
|
29
|
+
import { WebRtcTransportRequestorServer } from "./behaviors/WebRtcTransportRequestorServer.js";
|
|
26
30
|
import { BorderRouterDiscovery } from "./BorderRouterDiscovery.js";
|
|
27
31
|
import { ControllerCommandHandler } from "./ControllerCommandHandler.js";
|
|
28
32
|
import { LegacyDataInjector, LegacyServerData } from "./LegacyDataInjector.js";
|
|
@@ -62,6 +66,8 @@ export async function computeCompressedNodeId(
|
|
|
62
66
|
export interface MatterControllerOptions {
|
|
63
67
|
enableTestNetDcl?: boolean;
|
|
64
68
|
disableOtaProvider?: boolean;
|
|
69
|
+
/** Disable bundled offline DCL seed data (PAA roots, CD signers, vendors). When true, only network DCL is used. */
|
|
70
|
+
disableDclSeed?: boolean;
|
|
65
71
|
/** Server ID for storage. Default is "server", but may be "server-<hex(fabricId)>-<hex(vendorId)>" for multi-fabric support */
|
|
66
72
|
serverId?: string;
|
|
67
73
|
/** Server version string (e.g., "0.2.10" or "0.2.10-alpha.0"). Used for BasicInformation cluster. */
|
|
@@ -95,7 +101,9 @@ export class MatterController {
|
|
|
95
101
|
#legacyCommissionedDates?: Map<string, Timestamp>;
|
|
96
102
|
#enableTestNetDcl = false;
|
|
97
103
|
#disableOtaProvider = true;
|
|
104
|
+
#disableDclSeed = false;
|
|
98
105
|
readonly #borderRouterDiscovery: BorderRouterDiscovery;
|
|
106
|
+
#webRtcRequestor?: Endpoint<typeof CameraControllerDevice>;
|
|
99
107
|
|
|
100
108
|
static async create(
|
|
101
109
|
environment: Environment,
|
|
@@ -173,6 +181,7 @@ export class MatterController {
|
|
|
173
181
|
this.#serverVersion = options.serverVersion ?? "0.0.0";
|
|
174
182
|
this.#enableTestNetDcl = options.enableTestNetDcl ?? this.#enableTestNetDcl;
|
|
175
183
|
this.#disableOtaProvider = options.disableOtaProvider ?? this.#disableOtaProvider;
|
|
184
|
+
this.#disableDclSeed = options.disableDclSeed ?? this.#disableDclSeed;
|
|
176
185
|
}
|
|
177
186
|
|
|
178
187
|
protected async initialize(
|
|
@@ -181,6 +190,24 @@ export class MatterController {
|
|
|
181
190
|
legacyCommissionedDates?: Map<string, Timestamp>,
|
|
182
191
|
) {
|
|
183
192
|
this.#legacyCommissionedDates = legacyCommissionedDates?.size ? legacyCommissionedDates : undefined;
|
|
193
|
+
|
|
194
|
+
// Register DCL services on the root environment; DclBehavior picks them up.
|
|
195
|
+
// When seeding is enabled (default), pre-populate from the bundled offline snapshot so
|
|
196
|
+
// commissioning works without internet access.
|
|
197
|
+
const includeTest = this.#enableTestNetDcl;
|
|
198
|
+
if (this.#disableDclSeed) {
|
|
199
|
+
new DclCertificateService(this.#env.root, { fetchTestCertificates: includeTest });
|
|
200
|
+
} else {
|
|
201
|
+
new DclCertificateService(this.#env.root, {
|
|
202
|
+
fetchTestCertificates: includeTest,
|
|
203
|
+
seed: {
|
|
204
|
+
paaRoots: paaRoots({ includeTest }),
|
|
205
|
+
cdSigners: cdSigners({ includeTest }),
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
new DclVendorInfoService(this.#env.root, { seed: { vendors: vendors({ includeTest }) } });
|
|
209
|
+
}
|
|
210
|
+
|
|
184
211
|
this.#controllerInstance = new CommissioningController({
|
|
185
212
|
environment: {
|
|
186
213
|
environment: this.#env,
|
|
@@ -192,6 +219,8 @@ export class MatterController {
|
|
|
192
219
|
adminFabricId: fabricId !== undefined ? FabricId(fabricId) : undefined,
|
|
193
220
|
rootNodeId: NodeId(112233), // TODO Remove when we switch to random IDs
|
|
194
221
|
enableOtaProvider: !this.#disableOtaProvider,
|
|
222
|
+
tcp: true,
|
|
223
|
+
transportPreference: "tcp",
|
|
195
224
|
basicInformation: {
|
|
196
225
|
vendorName: "Open Home Foundation",
|
|
197
226
|
productName: "OHF Matter Server",
|
|
@@ -216,7 +245,8 @@ export class MatterController {
|
|
|
216
245
|
);
|
|
217
246
|
|
|
218
247
|
this.#commandHandler.events.started.once(async () => {
|
|
219
|
-
this.#controllerInstance!.node.behaviors.require(DclBehavior
|
|
248
|
+
this.#controllerInstance!.node.behaviors.require(DclBehavior);
|
|
249
|
+
await this.#controllerInstance!.node.setStateOf(DclBehavior, {
|
|
220
250
|
fetchTestCertificates: this.#enableTestNetDcl,
|
|
221
251
|
});
|
|
222
252
|
|
|
@@ -228,13 +258,14 @@ export class MatterController {
|
|
|
228
258
|
|
|
229
259
|
// Start loading and initialization of meta data
|
|
230
260
|
initPromises.push(this.vendorInfoService());
|
|
231
|
-
initPromises.push(this.certificateService());
|
|
261
|
+
// initPromises.push(this.certificateService()); // postponed to commissioning needs
|
|
232
262
|
|
|
233
263
|
if (!this.#disableOtaProvider && this.#enableTestNetDcl) {
|
|
234
264
|
initPromises.push(this.#enableTestOtaImages());
|
|
235
265
|
}
|
|
236
266
|
|
|
237
267
|
initPromises.push(this.#borderRouterDiscovery.start());
|
|
268
|
+
initPromises.push(this.#enableWebRtcRequestor());
|
|
238
269
|
|
|
239
270
|
try {
|
|
240
271
|
await MatterAggregateError.allSettled(initPromises);
|
|
@@ -251,6 +282,27 @@ export class MatterController {
|
|
|
251
282
|
return this.#borderRouterDiscovery;
|
|
252
283
|
}
|
|
253
284
|
|
|
285
|
+
get webRtcRequestor(): Endpoint<typeof CameraControllerDevice> {
|
|
286
|
+
if (!this.#webRtcRequestor) {
|
|
287
|
+
throw new Error("WebRTC requestor endpoint not initialized");
|
|
288
|
+
}
|
|
289
|
+
return this.#webRtcRequestor;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async #enableWebRtcRequestor(): Promise<void> {
|
|
293
|
+
if (!this.#controllerInstance) {
|
|
294
|
+
throw new Error("Controller not started");
|
|
295
|
+
}
|
|
296
|
+
const node = this.#controllerInstance.node;
|
|
297
|
+
if (node.endpoints.has("camera-controller")) {
|
|
298
|
+
this.#webRtcRequestor = node.endpoints.for("camera-controller") as Endpoint<typeof CameraControllerDevice>;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
this.#webRtcRequestor = await node.add(
|
|
302
|
+
new Endpoint(CameraControllerDevice.with(WebRtcTransportRequestorServer), { id: "camera-controller" }),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
254
306
|
/**
|
|
255
307
|
* Get the DCL vendor info service instance.
|
|
256
308
|
* Lazily initializes the service if not already present.
|
|
@@ -324,6 +376,7 @@ export class MatterController {
|
|
|
324
376
|
}
|
|
325
377
|
|
|
326
378
|
async stop() {
|
|
379
|
+
await this.certificateService(); // Ensure it was initialized so that shutdown works
|
|
327
380
|
await this.#borderRouterDiscovery.stop();
|
|
328
381
|
await this.#commandHandler?.close(); // This closes also the controller instance if started
|
|
329
382
|
}
|
package/src/controller/Nodes.ts
CHANGED
|
@@ -22,36 +22,24 @@ import { AttributeDataCache } from "./AttributeDataCache.js";
|
|
|
22
22
|
export class Nodes {
|
|
23
23
|
#nodes = new Map<NodeId, PairedNode>();
|
|
24
24
|
#attributeCache = new AttributeDataCache();
|
|
25
|
-
/**
|
|
26
|
-
#previousStates = new Map<NodeId, NodeStates>();
|
|
27
|
-
/** Cached availability so serialization and event paths always agree */
|
|
25
|
+
/** Cached so serialization and event paths always agree on availability. */
|
|
28
26
|
#lastAvailability = new Map<NodeId, boolean>();
|
|
29
27
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* (e.g., Home Assistant) can resolve node.endpoints[endpoint_id] in their callback.
|
|
28
|
+
* Buffered endpoint_added events. python-matter-server wire contract requires
|
|
29
|
+
* node_updated (carrying the new endpoint) to arrive before endpoint_added,
|
|
30
|
+
* so HA can resolve node.endpoints[endpoint_id] in its callback.
|
|
34
31
|
*/
|
|
35
32
|
#pendingEndpointAdds = new Map<NodeId, EndpointNumber[]>();
|
|
36
33
|
|
|
37
|
-
/**
|
|
38
|
-
* Get the attribute cache instance.
|
|
39
|
-
*/
|
|
40
34
|
get attributeCache(): AttributeDataCache {
|
|
41
35
|
return this.#attributeCache;
|
|
42
36
|
}
|
|
43
37
|
|
|
44
|
-
/**
|
|
45
|
-
* Get all node IDs.
|
|
46
|
-
*/
|
|
47
38
|
getIds(): NodeId[] {
|
|
48
39
|
return Array.from(this.#nodes.keys());
|
|
49
40
|
}
|
|
50
41
|
|
|
51
|
-
/**
|
|
52
|
-
* Get a node by ID.
|
|
53
|
-
* @throws ServerError if node not found
|
|
54
|
-
*/
|
|
42
|
+
/** @throws ServerError if node not found */
|
|
55
43
|
get(nodeId: NodeId): PairedNode {
|
|
56
44
|
const node = this.#nodes.get(nodeId);
|
|
57
45
|
if (node === undefined) {
|
|
@@ -60,34 +48,21 @@ export class Nodes {
|
|
|
60
48
|
return node;
|
|
61
49
|
}
|
|
62
50
|
|
|
63
|
-
/**
|
|
64
|
-
* Check if a node exists.
|
|
65
|
-
*/
|
|
66
51
|
has(nodeId: NodeId): boolean {
|
|
67
52
|
return this.#nodes.has(nodeId);
|
|
68
53
|
}
|
|
69
54
|
|
|
70
|
-
/**
|
|
71
|
-
* Add or update a node in storage.
|
|
72
|
-
*/
|
|
73
55
|
set(nodeId: NodeId, node: PairedNode): void {
|
|
74
56
|
this.#nodes.set(nodeId, node);
|
|
75
57
|
}
|
|
76
58
|
|
|
77
|
-
/**
|
|
78
|
-
* Remove a node from storage and clear its attribute cache and state tracking.
|
|
79
|
-
*/
|
|
80
59
|
delete(nodeId: NodeId): void {
|
|
81
60
|
this.#nodes.delete(nodeId);
|
|
82
61
|
this.#attributeCache.delete(nodeId);
|
|
83
|
-
this.#previousStates.delete(nodeId);
|
|
84
62
|
this.#lastAvailability.delete(nodeId);
|
|
85
63
|
this.#pendingEndpointAdds.delete(nodeId);
|
|
86
64
|
}
|
|
87
65
|
|
|
88
|
-
/**
|
|
89
|
-
* Buffer an endpoint_added until the next nodeStructureChanged for that node.
|
|
90
|
-
*/
|
|
91
66
|
queueEndpointAdded(nodeId: NodeId, endpointId: EndpointNumber): void {
|
|
92
67
|
let queue = this.#pendingEndpointAdds.get(nodeId);
|
|
93
68
|
if (queue === undefined) {
|
|
@@ -97,10 +72,7 @@ export class Nodes {
|
|
|
97
72
|
queue.push(endpointId);
|
|
98
73
|
}
|
|
99
74
|
|
|
100
|
-
/**
|
|
101
|
-
* Take ownership of buffered endpoint additions for a node and clear the queue.
|
|
102
|
-
* Returned array is in insertion order; an empty array is returned if nothing is queued.
|
|
103
|
-
*/
|
|
75
|
+
/** Returns insertion-ordered queue; empty if nothing pending. */
|
|
104
76
|
drainPendingEndpointAdds(nodeId: NodeId): EndpointNumber[] {
|
|
105
77
|
const queue = this.#pendingEndpointAdds.get(nodeId);
|
|
106
78
|
if (queue === undefined || queue.length === 0) {
|
|
@@ -110,30 +82,19 @@ export class Nodes {
|
|
|
110
82
|
return queue;
|
|
111
83
|
}
|
|
112
84
|
|
|
113
|
-
/**
|
|
114
|
-
* Initialize state tracking for a newly paired/discovered node.
|
|
115
|
-
* Sets previous state and initial availability so the first stateChanged event
|
|
116
|
-
* has a real previous state instead of undefined.
|
|
117
|
-
*/
|
|
118
85
|
seedState(nodeId: NodeId, initialState: NodeStates): void {
|
|
119
|
-
this.#previousStates.set(nodeId, initialState);
|
|
120
86
|
this.#lastAvailability.set(nodeId, initialState === NodeStates.Connected);
|
|
121
87
|
}
|
|
122
88
|
|
|
123
|
-
/**
|
|
124
|
-
* Process a state change for a node. Reads previous state BEFORE updating,
|
|
125
|
-
* computes new availability, and updates both tracking maps atomically.
|
|
126
|
-
* Returns whether availability changed and the new value.
|
|
127
|
-
*/
|
|
89
|
+
/** `debouncePending` = reconnect timer armed by caller; keeps non-Connected states available. */
|
|
128
90
|
processStateChange(
|
|
129
91
|
nodeId: NodeId,
|
|
130
92
|
newState: NodeStates,
|
|
93
|
+
debouncePending: boolean,
|
|
131
94
|
): { availabilityChanged: true; available: boolean } | { availabilityChanged: false } {
|
|
132
|
-
const previousState = this.#previousStates.get(nodeId);
|
|
133
95
|
const wasAvailable = this.#lastAvailability.get(nodeId) ?? false;
|
|
134
|
-
const available = this.isNodeAvailable(newState,
|
|
96
|
+
const available = this.isNodeAvailable(newState, debouncePending);
|
|
135
97
|
|
|
136
|
-
this.#previousStates.set(nodeId, newState);
|
|
137
98
|
this.#lastAvailability.set(nodeId, available);
|
|
138
99
|
|
|
139
100
|
if (wasAvailable !== available) {
|
|
@@ -142,47 +103,21 @@ export class Nodes {
|
|
|
142
103
|
return { availabilityChanged: false };
|
|
143
104
|
}
|
|
144
105
|
|
|
145
|
-
/**
|
|
146
|
-
* Force a node to unavailable state. Used by reconnect timeout
|
|
147
|
-
* when debounce period expires without reconnection.
|
|
148
|
-
* Only updates #lastAvailability — #previousStates is left as-is because
|
|
149
|
-
* processStateChange() will overwrite it on the next real state transition.
|
|
150
|
-
* Returns true if the node was previously considered available.
|
|
151
|
-
*/
|
|
106
|
+
/** Returns true if the node was previously considered available. */
|
|
152
107
|
forceUnavailable(nodeId: NodeId): boolean {
|
|
153
108
|
const wasAvailable = this.#lastAvailability.get(nodeId) ?? false;
|
|
154
109
|
this.#lastAvailability.set(nodeId, false);
|
|
155
110
|
return wasAvailable;
|
|
156
111
|
}
|
|
157
112
|
|
|
158
|
-
|
|
159
|
-
* Determine if a node should be considered available based on its connection state.
|
|
160
|
-
* Uses debouncing logic similar to Python Matter Server:
|
|
161
|
-
* - Connected: available
|
|
162
|
-
* - Reconnecting when previously Connected: still available (debouncing)
|
|
163
|
-
* - WaitingForDeviceDiscovery or Disconnected: unavailable
|
|
164
|
-
*
|
|
165
|
-
* @param currentState Current connection state
|
|
166
|
-
* @param previousState Previous connection state (undefined if first state change)
|
|
167
|
-
* @returns true if node should be considered available
|
|
168
|
-
*/
|
|
169
|
-
isNodeAvailable(currentState: NodeStates, previousState?: NodeStates): boolean {
|
|
113
|
+
isNodeAvailable(currentState: NodeStates, debouncePending = false): boolean {
|
|
170
114
|
if (currentState === NodeStates.Connected) {
|
|
171
115
|
return true;
|
|
172
116
|
}
|
|
173
|
-
|
|
174
|
-
if (currentState === NodeStates.Reconnecting && previousState === NodeStates.Connected) {
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
// WaitingForDeviceDiscovery, Disconnected, or Reconnecting from non-connected state
|
|
178
|
-
return false;
|
|
117
|
+
return debouncePending;
|
|
179
118
|
}
|
|
180
119
|
|
|
181
|
-
/**
|
|
182
|
-
* Check if a node is available. Returns the cached availability value set by
|
|
183
|
-
* seedState/processStateChange/forceUnavailable, avoiding the race where
|
|
184
|
-
* recomputing from live state disagrees with the event path's determination.
|
|
185
|
-
*/
|
|
120
|
+
/** Returns the cached value, not a recomputation — avoids disagreement with the event path. */
|
|
186
121
|
isAvailable(nodeId: NodeId): boolean {
|
|
187
122
|
return this.#lastAvailability.get(nodeId) ?? false;
|
|
188
123
|
}
|