@matter-server/ws-controller 0.2.0-alpha.0-00000000-000000000
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/LICENSE +201 -0
- package/README.md +11 -0
- package/dist/esm/controller/AttributeDataCache.d.ts +49 -0
- package/dist/esm/controller/AttributeDataCache.d.ts.map +1 -0
- package/dist/esm/controller/AttributeDataCache.js +154 -0
- package/dist/esm/controller/AttributeDataCache.js.map +6 -0
- package/dist/esm/controller/ControllerCommandHandler.d.ts +118 -0
- package/dist/esm/controller/ControllerCommandHandler.d.ts.map +1 -0
- package/dist/esm/controller/ControllerCommandHandler.js +1015 -0
- package/dist/esm/controller/ControllerCommandHandler.js.map +6 -0
- package/dist/esm/controller/LegacyDataInjector.d.ts +95 -0
- package/dist/esm/controller/LegacyDataInjector.d.ts.map +1 -0
- package/dist/esm/controller/LegacyDataInjector.js +196 -0
- package/dist/esm/controller/LegacyDataInjector.js.map +6 -0
- package/dist/esm/controller/MatterController.d.ts +59 -0
- package/dist/esm/controller/MatterController.d.ts.map +1 -0
- package/dist/esm/controller/MatterController.js +212 -0
- package/dist/esm/controller/MatterController.js.map +6 -0
- package/dist/esm/controller/Nodes.d.ts +62 -0
- package/dist/esm/controller/Nodes.d.ts.map +1 -0
- package/dist/esm/controller/Nodes.js +85 -0
- package/dist/esm/controller/Nodes.js.map +6 -0
- package/dist/esm/controller/TestNodeCommandHandler.d.ts +84 -0
- package/dist/esm/controller/TestNodeCommandHandler.d.ts.map +1 -0
- package/dist/esm/controller/TestNodeCommandHandler.js +225 -0
- package/dist/esm/controller/TestNodeCommandHandler.js.map +6 -0
- package/dist/esm/data/VendorIDs.d.ts +7 -0
- package/dist/esm/data/VendorIDs.d.ts.map +1 -0
- package/dist/esm/data/VendorIDs.js +1237 -0
- package/dist/esm/data/VendorIDs.js.map +6 -0
- package/dist/esm/example/send-command.d.ts +7 -0
- package/dist/esm/example/send-command.d.ts.map +1 -0
- package/dist/esm/example/send-command.js +60 -0
- package/dist/esm/example/send-command.js.map +6 -0
- package/dist/esm/index.d.ts +21 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +26 -0
- package/dist/esm/index.js.map +6 -0
- package/dist/esm/model/ModelMapper.d.ts +34 -0
- package/dist/esm/model/ModelMapper.d.ts.map +1 -0
- package/dist/esm/model/ModelMapper.js +62 -0
- package/dist/esm/model/ModelMapper.js.map +6 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/server/ConfigStorage.d.ts +29 -0
- package/dist/esm/server/ConfigStorage.d.ts.map +1 -0
- package/dist/esm/server/ConfigStorage.js +84 -0
- package/dist/esm/server/ConfigStorage.js.map +6 -0
- package/dist/esm/server/Converters.d.ts +53 -0
- package/dist/esm/server/Converters.d.ts.map +1 -0
- package/dist/esm/server/Converters.js +343 -0
- package/dist/esm/server/Converters.js.map +6 -0
- package/dist/esm/server/WebSocketControllerHandler.d.ts +21 -0
- package/dist/esm/server/WebSocketControllerHandler.d.ts.map +1 -0
- package/dist/esm/server/WebSocketControllerHandler.js +767 -0
- package/dist/esm/server/WebSocketControllerHandler.js.map +6 -0
- package/dist/esm/types/CommandHandler.d.ts +258 -0
- package/dist/esm/types/CommandHandler.d.ts.map +1 -0
- package/dist/esm/types/CommandHandler.js +6 -0
- package/dist/esm/types/CommandHandler.js.map +6 -0
- package/dist/esm/types/WebServer.d.ts +12 -0
- package/dist/esm/types/WebServer.d.ts.map +1 -0
- package/dist/esm/types/WebServer.js +6 -0
- package/dist/esm/types/WebServer.js.map +6 -0
- package/dist/esm/types/WebSocketMessageTypes.d.ts +478 -0
- package/dist/esm/types/WebSocketMessageTypes.d.ts.map +1 -0
- package/dist/esm/types/WebSocketMessageTypes.js +77 -0
- package/dist/esm/types/WebSocketMessageTypes.js.map +6 -0
- package/dist/esm/util/matterVersion.d.ts +12 -0
- package/dist/esm/util/matterVersion.d.ts.map +1 -0
- package/dist/esm/util/matterVersion.js +32 -0
- package/dist/esm/util/matterVersion.js.map +6 -0
- package/dist/esm/util/network.d.ts +14 -0
- package/dist/esm/util/network.d.ts.map +1 -0
- package/dist/esm/util/network.js +63 -0
- package/dist/esm/util/network.js.map +6 -0
- package/package.json +45 -0
- package/src/controller/AttributeDataCache.ts +194 -0
- package/src/controller/ControllerCommandHandler.ts +1256 -0
- package/src/controller/LegacyDataInjector.ts +314 -0
- package/src/controller/MatterController.ts +265 -0
- package/src/controller/Nodes.ts +115 -0
- package/src/controller/TestNodeCommandHandler.ts +305 -0
- package/src/data/VendorIDs.ts +1234 -0
- package/src/example/send-command.ts +82 -0
- package/src/index.ts +33 -0
- package/src/model/ModelMapper.ts +87 -0
- package/src/server/ConfigStorage.ts +112 -0
- package/src/server/Converters.ts +483 -0
- package/src/server/WebSocketControllerHandler.ts +917 -0
- package/src/tsconfig.json +7 -0
- package/src/types/CommandHandler.ts +270 -0
- package/src/types/WebServer.ts +14 -0
- package/src/types/WebSocketMessageTypes.ts +525 -0
- package/src/util/matterVersion.ts +45 -0
- package/src/util/network.ts +85 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025-2026 Open Home Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { AsyncObservable, isObject } from "@matter/general";
|
|
8
|
+
import {
|
|
9
|
+
Bytes,
|
|
10
|
+
ClientNodeInteraction,
|
|
11
|
+
ClusterBehavior,
|
|
12
|
+
FabricId,
|
|
13
|
+
FabricIndex,
|
|
14
|
+
ipv4BytesToString,
|
|
15
|
+
ipv6BytesToString,
|
|
16
|
+
Logger,
|
|
17
|
+
Millis,
|
|
18
|
+
NodeId,
|
|
19
|
+
Observable,
|
|
20
|
+
Seconds,
|
|
21
|
+
ServerAddress,
|
|
22
|
+
ServerAddressUdp,
|
|
23
|
+
SoftwareUpdateInfo,
|
|
24
|
+
SoftwareUpdateManager,
|
|
25
|
+
} from "@matter/main";
|
|
26
|
+
import {
|
|
27
|
+
AccessControl,
|
|
28
|
+
Binding,
|
|
29
|
+
GeneralCommissioning,
|
|
30
|
+
GeneralDiagnosticsCluster,
|
|
31
|
+
OperationalCredentials,
|
|
32
|
+
} from "@matter/main/clusters";
|
|
33
|
+
import {
|
|
34
|
+
DecodedAttributeReportValue,
|
|
35
|
+
DecodedEventReportValue,
|
|
36
|
+
PeerAddress,
|
|
37
|
+
Read,
|
|
38
|
+
SignatureFromCommandSpec,
|
|
39
|
+
SupportedTransportsSchema,
|
|
40
|
+
} from "@matter/main/protocol";
|
|
41
|
+
import {
|
|
42
|
+
Attribute,
|
|
43
|
+
ClusterId,
|
|
44
|
+
Command,
|
|
45
|
+
DeviceTypeId,
|
|
46
|
+
EndpointNumber,
|
|
47
|
+
getClusterById,
|
|
48
|
+
GroupId,
|
|
49
|
+
ManualPairingCodeCodec,
|
|
50
|
+
QrPairingCodeCodec,
|
|
51
|
+
StatusResponseError,
|
|
52
|
+
TlvAny,
|
|
53
|
+
TlvBoolean,
|
|
54
|
+
TlvByteString,
|
|
55
|
+
TlvInt32,
|
|
56
|
+
TlvNoResponse,
|
|
57
|
+
TlvNullable,
|
|
58
|
+
TlvObject,
|
|
59
|
+
TlvString,
|
|
60
|
+
TlvUInt64,
|
|
61
|
+
TlvVoid,
|
|
62
|
+
VendorId,
|
|
63
|
+
} from "@matter/main/types";
|
|
64
|
+
import { CommissioningController, NodeCommissioningOptions } from "@project-chip/matter.js";
|
|
65
|
+
import { Endpoint, NodeStates } from "@project-chip/matter.js/device";
|
|
66
|
+
import { ClusterMap, ClusterMapEntry } from "../model/ModelMapper.js";
|
|
67
|
+
import {
|
|
68
|
+
buildAttributePath,
|
|
69
|
+
convertCommandDataToMatter,
|
|
70
|
+
convertMatterToWebSocketTagBased,
|
|
71
|
+
getDateAsString,
|
|
72
|
+
splitAttributePath,
|
|
73
|
+
} from "../server/Converters.js";
|
|
74
|
+
import {
|
|
75
|
+
AttributeResponseStatus,
|
|
76
|
+
AttributesData,
|
|
77
|
+
CommissioningRequest,
|
|
78
|
+
CommissioningResponse,
|
|
79
|
+
DiscoveryRequest,
|
|
80
|
+
DiscoveryResponse,
|
|
81
|
+
InvokeByIdRequest,
|
|
82
|
+
InvokeRequest,
|
|
83
|
+
MatterNodeData,
|
|
84
|
+
OpenCommissioningWindowRequest,
|
|
85
|
+
OpenCommissioningWindowResponse,
|
|
86
|
+
ReadAttributeRequest,
|
|
87
|
+
ReadAttributeResponse,
|
|
88
|
+
ReadEventRequest,
|
|
89
|
+
ReadEventResponse,
|
|
90
|
+
SubscribeAttributeRequest,
|
|
91
|
+
SubscribeAttributeResponse,
|
|
92
|
+
SubscribeEventRequest,
|
|
93
|
+
SubscribeEventResponse,
|
|
94
|
+
WriteAttributeByIdRequest,
|
|
95
|
+
WriteAttributeRequest,
|
|
96
|
+
} from "../types/CommandHandler.js";
|
|
97
|
+
import {
|
|
98
|
+
AccessControlEntry,
|
|
99
|
+
AccessControlTarget,
|
|
100
|
+
AttributeWriteResult,
|
|
101
|
+
BindingTarget,
|
|
102
|
+
MatterSoftwareVersion,
|
|
103
|
+
NodePingResult,
|
|
104
|
+
UpdateSource,
|
|
105
|
+
} from "../types/WebSocketMessageTypes.js";
|
|
106
|
+
import { pingIp } from "../util/network.js";
|
|
107
|
+
import { Nodes } from "./Nodes.js";
|
|
108
|
+
|
|
109
|
+
const logger = Logger.get("ControllerCommandHandler");
|
|
110
|
+
|
|
111
|
+
export class ControllerCommandHandler {
|
|
112
|
+
#controller: CommissioningController;
|
|
113
|
+
#started = false;
|
|
114
|
+
#connected = false;
|
|
115
|
+
#bleEnabled = false;
|
|
116
|
+
#otaEnabled = false;
|
|
117
|
+
/** Node management and attribute cache */
|
|
118
|
+
#nodes = new Nodes();
|
|
119
|
+
/** Cache of available updates keyed by nodeId */
|
|
120
|
+
#availableUpdates = new Map<NodeId, SoftwareUpdateInfo>();
|
|
121
|
+
events = {
|
|
122
|
+
started: new AsyncObservable(),
|
|
123
|
+
attributeChanged: new Observable<[nodeId: NodeId, data: DecodedAttributeReportValue<any>]>(),
|
|
124
|
+
eventChanged: new Observable<[nodeId: NodeId, data: DecodedEventReportValue<any>]>(),
|
|
125
|
+
nodeAdded: new Observable<[nodeId: NodeId]>(),
|
|
126
|
+
nodeStateChanged: new Observable<[nodeId: NodeId, state: NodeStates]>(),
|
|
127
|
+
nodeStructureChanged: new Observable<[nodeId: NodeId]>(),
|
|
128
|
+
nodeDecommissioned: new Observable<[nodeId: NodeId]>(),
|
|
129
|
+
nodeEndpointAdded: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(),
|
|
130
|
+
nodeEndpointRemoved: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
constructor(controllerInstance: CommissioningController, bleEnabled: boolean, otaEnabled: boolean) {
|
|
134
|
+
this.#controller = controllerInstance;
|
|
135
|
+
|
|
136
|
+
this.#bleEnabled = bleEnabled;
|
|
137
|
+
this.#otaEnabled = otaEnabled;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get started() {
|
|
141
|
+
return this.#started;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get bleEnabled() {
|
|
145
|
+
return this.#bleEnabled;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async start() {
|
|
149
|
+
if (this.#started) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.#started = true;
|
|
153
|
+
|
|
154
|
+
await this.#controller.start();
|
|
155
|
+
logger.info(`Controller started`);
|
|
156
|
+
|
|
157
|
+
if (!this.#bleEnabled) {
|
|
158
|
+
// Subscribe to OTA provider events to track available updates
|
|
159
|
+
await this.#setupOtaEventHandlers();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await this.events.started.emit();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Set up event handlers for OTA update notifications from the SoftwareUpdateManager.
|
|
167
|
+
*/
|
|
168
|
+
async #setupOtaEventHandlers() {
|
|
169
|
+
if (!this.#otaEnabled) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const otaProvider = this.#controller.otaProvider;
|
|
174
|
+
if (!otaProvider) {
|
|
175
|
+
logger.info("OTA provider not available");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Access the SoftwareUpdateManager behavior events dynamically
|
|
180
|
+
// Using 'any' because SoftwareUpdateManager is not directly exported from @matter/node
|
|
181
|
+
const softwareUpdateManagerEvents = await otaProvider.act(agent => agent.get(SoftwareUpdateManager).events);
|
|
182
|
+
if (softwareUpdateManagerEvents === undefined) {
|
|
183
|
+
logger.info("SoftwareUpdateManager not available");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Handle updateAvailable events - cache the update info
|
|
188
|
+
softwareUpdateManagerEvents.updateAvailable.on(
|
|
189
|
+
(peerAddress: PeerAddress, updateDetails: SoftwareUpdateInfo) => {
|
|
190
|
+
logger.info(`Update available for node ${peerAddress.nodeId}:`, updateDetails);
|
|
191
|
+
this.#availableUpdates.set(peerAddress.nodeId, updateDetails);
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Handle updateDone events - clear the cached update info
|
|
196
|
+
softwareUpdateManagerEvents.updateDone.on((peerAddress: PeerAddress) => {
|
|
197
|
+
logger.info(`Update done for node ${peerAddress.nodeId}`);
|
|
198
|
+
this.#availableUpdates.delete(peerAddress.nodeId);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
logger.info("OTA event handlers registered");
|
|
202
|
+
} catch (error) {
|
|
203
|
+
logger.warn("Failed to setup OTA event handlers:", error);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
close() {
|
|
208
|
+
if (!this.#started) return;
|
|
209
|
+
return this.#controller.close();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async #registerNode(nodeId: NodeId) {
|
|
213
|
+
const node = await this.#controller.getNode(nodeId);
|
|
214
|
+
const attributeCache = this.#nodes.attributeCache;
|
|
215
|
+
|
|
216
|
+
// Wire all Events to the Event emitters
|
|
217
|
+
node.events.attributeChanged.on(data => {
|
|
218
|
+
// Update the attribute cache with the new value in WebSocket format
|
|
219
|
+
attributeCache.updateAttribute(nodeId, data);
|
|
220
|
+
// Then emit the event for listeners
|
|
221
|
+
this.events.attributeChanged.emit(nodeId, data);
|
|
222
|
+
});
|
|
223
|
+
node.events.eventTriggered.on(data => this.events.eventChanged.emit(nodeId, data));
|
|
224
|
+
node.events.stateChanged.on(state => {
|
|
225
|
+
// Only refresh cache on Connected state (not Reconnecting, WaitingForDiscovery, etc.)
|
|
226
|
+
if (state === NodeStates.Connected) {
|
|
227
|
+
attributeCache.update(node);
|
|
228
|
+
}
|
|
229
|
+
this.events.nodeStateChanged.emit(nodeId, state);
|
|
230
|
+
});
|
|
231
|
+
node.events.structureChanged.on(() => {
|
|
232
|
+
// Structure changed means endpoints may have been added/removed, refresh cache
|
|
233
|
+
if (node.isConnected) {
|
|
234
|
+
attributeCache.update(node);
|
|
235
|
+
}
|
|
236
|
+
this.events.nodeStructureChanged.emit(nodeId);
|
|
237
|
+
});
|
|
238
|
+
node.events.decommissioned.on(() => this.events.nodeDecommissioned.emit(nodeId));
|
|
239
|
+
node.events.nodeEndpointAdded.on(endpointId => this.events.nodeEndpointAdded.emit(nodeId, endpointId));
|
|
240
|
+
node.events.nodeEndpointRemoved.on(endpointId => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
|
|
241
|
+
|
|
242
|
+
// Store the node for direct access
|
|
243
|
+
this.#nodes.set(nodeId, node);
|
|
244
|
+
|
|
245
|
+
// Initialize attribute cache if node is already initialized
|
|
246
|
+
if (node.initialized) {
|
|
247
|
+
attributeCache.add(node);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return node;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async connect() {
|
|
254
|
+
if (this.#connected) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.#connected = true;
|
|
258
|
+
|
|
259
|
+
await this.start();
|
|
260
|
+
|
|
261
|
+
const nodes = this.#controller.getCommissionedNodes();
|
|
262
|
+
logger.info(`Found ${nodes.length} nodes: ${nodes.map(nodeId => nodeId.toString()).join(", ")}`);
|
|
263
|
+
|
|
264
|
+
for (const nodeId of nodes) {
|
|
265
|
+
try {
|
|
266
|
+
logger.info(`Initializing node "${nodeId}" ...`);
|
|
267
|
+
// Initialize Node
|
|
268
|
+
const node = await this.#registerNode(nodeId);
|
|
269
|
+
|
|
270
|
+
// Trigger connect to node, default values are used
|
|
271
|
+
node.connect({
|
|
272
|
+
subscribeMinIntervalFloorSeconds: 1,
|
|
273
|
+
subscribeMaxIntervalCeilingSeconds: undefined,
|
|
274
|
+
});
|
|
275
|
+
} catch (error) {
|
|
276
|
+
logger.warn(`Failed to connect to node "${nodeId}":`, error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
getNodeIds() {
|
|
282
|
+
return this.#nodes.getIds();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
hasNode(nodeId: NodeId): boolean {
|
|
286
|
+
return this.#nodes.has(nodeId);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Alias for decommissionNode to match NodeCommandHandler interface.
|
|
291
|
+
*/
|
|
292
|
+
removeNode(nodeId: NodeId) {
|
|
293
|
+
return this.decommissionNode(nodeId);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async interviewNode(nodeId: NodeId) {
|
|
297
|
+
const node = this.#nodes.get(nodeId);
|
|
298
|
+
|
|
299
|
+
// Our nodes are kept up-to-date via attribute subscriptions, so we don't need
|
|
300
|
+
// to re-read all attributes like the Python server does.
|
|
301
|
+
// Just emit a node_updated event with the current (already fresh) data.
|
|
302
|
+
logger.info(`Interview requested for node ${nodeId} - do a complete read`);
|
|
303
|
+
|
|
304
|
+
// Do a full Read of the node
|
|
305
|
+
const read = {
|
|
306
|
+
...Read({
|
|
307
|
+
fabricFilter: true,
|
|
308
|
+
attributes: [{}],
|
|
309
|
+
}),
|
|
310
|
+
includeKnownVersions: true, // do not send DataVersionFilters, so we do a new clean read
|
|
311
|
+
};
|
|
312
|
+
for await (const _chunk of (node.node.interaction as ClientNodeInteraction).read(read));
|
|
313
|
+
|
|
314
|
+
// Emit node_updated event (same as Python server behavior after the interview)
|
|
315
|
+
this.events.nodeStateChanged.emit(nodeId, node.connectionState);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get full node details in WebSocket API format.
|
|
320
|
+
* @param nodeId The node ID
|
|
321
|
+
* @param lastInterviewDate Optional last interview date (tracked externally)
|
|
322
|
+
*/
|
|
323
|
+
async getNodeDetails(nodeId: NodeId, lastInterviewDate?: Date): Promise<MatterNodeData> {
|
|
324
|
+
const node = this.#nodes.get(nodeId);
|
|
325
|
+
const attributeCache = this.#nodes.attributeCache;
|
|
326
|
+
|
|
327
|
+
let isBridge = false;
|
|
328
|
+
|
|
329
|
+
// Ensure the cache is populated if node is initialized but cache doesn't exist yet
|
|
330
|
+
if (!attributeCache.has(nodeId)) {
|
|
331
|
+
attributeCache.add(node);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Get cached attributes (empty object if node not yet initialized)
|
|
335
|
+
const attributes = attributeCache.get(nodeId) ?? {};
|
|
336
|
+
|
|
337
|
+
// Bridge detection: Check endpoint 1's Descriptor cluster (29) DeviceTypeList attribute (0)
|
|
338
|
+
// for device type 14 (Aggregator), matching Python Matter Server behavior
|
|
339
|
+
const endpoint1DeviceTypes = attributes["1/29/0"];
|
|
340
|
+
if (Array.isArray(endpoint1DeviceTypes)) {
|
|
341
|
+
isBridge = endpoint1DeviceTypes.some(entry => entry["0"] === 14);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Get commissioned date from node state if available
|
|
345
|
+
const commissionedAt = node.state.commissioning.commissionedAt;
|
|
346
|
+
const dateCommissioned = commissionedAt !== undefined ? new Date(commissionedAt) : new Date();
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
node_id: node.nodeId,
|
|
350
|
+
date_commissioned: getDateAsString(dateCommissioned),
|
|
351
|
+
last_interview: getDateAsString(lastInterviewDate ?? new Date()),
|
|
352
|
+
interview_version: 6,
|
|
353
|
+
available: node.isConnected,
|
|
354
|
+
is_bridge: isBridge,
|
|
355
|
+
attributes,
|
|
356
|
+
attribute_subscriptions: [],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Read multiple attributes from a node by path strings.
|
|
362
|
+
* Handles wildcards in paths.
|
|
363
|
+
*/
|
|
364
|
+
async handleReadAttributes(
|
|
365
|
+
nodeId: NodeId,
|
|
366
|
+
attributePaths: string[],
|
|
367
|
+
fabricFiltered = false,
|
|
368
|
+
): Promise<AttributesData> {
|
|
369
|
+
const node = this.#nodes.get(nodeId);
|
|
370
|
+
|
|
371
|
+
const result: AttributesData = {};
|
|
372
|
+
|
|
373
|
+
// Check if any path contains wildcards - if so, we need to collect all attributes from node
|
|
374
|
+
const hasWildcards = attributePaths.some(path => path.includes("*"));
|
|
375
|
+
let allAttributes: AttributesData | undefined;
|
|
376
|
+
|
|
377
|
+
if (hasWildcards) {
|
|
378
|
+
if (!node.initialized) {
|
|
379
|
+
throw new Error(`Node ${nodeId} not ready`);
|
|
380
|
+
}
|
|
381
|
+
const rootEndpoint = node.getRootEndpoint();
|
|
382
|
+
if (rootEndpoint === undefined) {
|
|
383
|
+
throw new Error(`Node ${nodeId} not ready`);
|
|
384
|
+
}
|
|
385
|
+
allAttributes = {};
|
|
386
|
+
this.#collectAttributesFromEndpoint(rootEndpoint, allAttributes);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Process each attribute path
|
|
390
|
+
for (const path of attributePaths) {
|
|
391
|
+
const { endpointId, clusterId, attributeId } = splitAttributePath(path);
|
|
392
|
+
|
|
393
|
+
// For wildcard paths, filter from collected attributes
|
|
394
|
+
if (path.includes("*") && allAttributes !== undefined) {
|
|
395
|
+
for (const [attrPath, value] of Object.entries(allAttributes)) {
|
|
396
|
+
const parts = attrPath.split("/").map(Number);
|
|
397
|
+
if (
|
|
398
|
+
(endpointId === undefined || parts[0] === endpointId) &&
|
|
399
|
+
(clusterId === undefined || parts[1] === clusterId) &&
|
|
400
|
+
(attributeId === undefined || parts[2] === attributeId)
|
|
401
|
+
) {
|
|
402
|
+
result[attrPath] = value;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// For non-wildcard paths, use the SDK to read the specific attribute
|
|
409
|
+
const { values, status } = await this.handleReadAttribute({
|
|
410
|
+
nodeId,
|
|
411
|
+
endpointId,
|
|
412
|
+
clusterId,
|
|
413
|
+
attributeId,
|
|
414
|
+
fabricFiltered,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
if (values.length) {
|
|
418
|
+
for (const valueData of values) {
|
|
419
|
+
const { pathStr, value } = this.#convertAttributeToWebSocket(
|
|
420
|
+
{
|
|
421
|
+
endpointId: EndpointNumber(valueData.endpointId),
|
|
422
|
+
clusterId: ClusterId(valueData.clusterId),
|
|
423
|
+
attributeId: valueData.attributeId,
|
|
424
|
+
},
|
|
425
|
+
valueData.value,
|
|
426
|
+
);
|
|
427
|
+
result[pathStr] = value;
|
|
428
|
+
}
|
|
429
|
+
} else if (status && status.length > 0) {
|
|
430
|
+
logger.warn(`Failed to read attribute ${path}: status=${JSON.stringify(status)}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Collect all attributes from an endpoint and its children into WebSocket format.
|
|
439
|
+
*/
|
|
440
|
+
#collectAttributesFromEndpoint(endpoint: Endpoint, attributesData: AttributesData) {
|
|
441
|
+
const endpointId = endpoint.number!;
|
|
442
|
+
for (const behavior of endpoint.endpoint.behaviors.active) {
|
|
443
|
+
if (!ClusterBehavior.is(behavior)) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const cluster = behavior.cluster;
|
|
447
|
+
const clusterId = cluster.id;
|
|
448
|
+
const clusterData = ClusterMap[cluster.name.toLowerCase()];
|
|
449
|
+
const clusterState = endpoint.endpoint.stateOf(behavior);
|
|
450
|
+
|
|
451
|
+
for (const attributeName in cluster.attributes) {
|
|
452
|
+
const attribute = cluster.attributes[attributeName];
|
|
453
|
+
if (attribute === undefined) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const attributeValue = (clusterState as Record<string, unknown>)[attributeName];
|
|
457
|
+
const { pathStr, value } = this.#convertAttributeToWebSocket(
|
|
458
|
+
{ endpointId, clusterId, attributeId: attribute.id },
|
|
459
|
+
attributeValue,
|
|
460
|
+
clusterData,
|
|
461
|
+
);
|
|
462
|
+
attributesData[pathStr] = value;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Recursively collect from child endpoints
|
|
467
|
+
for (const childEndpoint of endpoint.getChildEndpoints()) {
|
|
468
|
+
this.#collectAttributesFromEndpoint(childEndpoint, attributesData);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Convert attribute data to WebSocket tag-based format.
|
|
474
|
+
*/
|
|
475
|
+
#convertAttributeToWebSocket(
|
|
476
|
+
path: { endpointId: EndpointNumber; clusterId: ClusterId; attributeId: number },
|
|
477
|
+
value: unknown,
|
|
478
|
+
clusterData?: ClusterMapEntry,
|
|
479
|
+
) {
|
|
480
|
+
const { endpointId, clusterId, attributeId } = path;
|
|
481
|
+
if (!clusterData) {
|
|
482
|
+
const cluster = getClusterById(clusterId);
|
|
483
|
+
clusterData = ClusterMap[cluster.name.toLowerCase()];
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
pathStr: buildAttributePath(endpointId, clusterId, attributeId),
|
|
487
|
+
value: convertMatterToWebSocketTagBased(value, clusterData?.attributes[attributeId], clusterData?.model),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Set the fabric label. Pass null or empty string to reset to "Home".
|
|
493
|
+
* Note: matter.js requires non-empty labels (1-32 chars), so null/empty resets to default.
|
|
494
|
+
*/
|
|
495
|
+
setFabricLabel(label: string | null) {
|
|
496
|
+
const effectiveLabel = label && label.trim() !== "" ? label : "Home";
|
|
497
|
+
return this.#controller.updateFabricLabel(effectiveLabel);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
disconnectNode(nodeId: NodeId) {
|
|
501
|
+
return this.#controller.disconnectNode(nodeId, true);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async handleReadAttribute(data: ReadAttributeRequest): Promise<ReadAttributeResponse> {
|
|
505
|
+
const { nodeId, endpointId, clusterId, attributeId, fabricFiltered = true } = data;
|
|
506
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
507
|
+
|
|
508
|
+
// Note: This method is for direct SDK reads with explicit paths.
|
|
509
|
+
// Wildcard handling is done at the WebSocket layer before calling this method.
|
|
510
|
+
const { attributeData, attributeStatus } = await client.getMultipleAttributesAndStatus({
|
|
511
|
+
attributes: [{ endpointId, clusterId, attributeId }],
|
|
512
|
+
isFabricFiltered: fabricFiltered,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
values: attributeData.map(
|
|
517
|
+
({ path: { endpointId, clusterId, attributeId }, value, version: dataVersion }) => ({
|
|
518
|
+
attributeId,
|
|
519
|
+
clusterId,
|
|
520
|
+
dataVersion,
|
|
521
|
+
endpointId,
|
|
522
|
+
value,
|
|
523
|
+
}),
|
|
524
|
+
),
|
|
525
|
+
status: attributeStatus?.map(({ path: { endpointId, clusterId, attributeId }, status, clusterStatus }) => ({
|
|
526
|
+
attributeId,
|
|
527
|
+
clusterId,
|
|
528
|
+
endpointId,
|
|
529
|
+
status,
|
|
530
|
+
clusterStatus,
|
|
531
|
+
})),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async handleReadEvent(data: ReadEventRequest): Promise<ReadEventResponse> {
|
|
536
|
+
const { nodeId, endpointId, clusterId, eventId, eventMin } = data;
|
|
537
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
538
|
+
const { eventData, eventStatus } = await client.getMultipleEventsAndStatus({
|
|
539
|
+
events: [
|
|
540
|
+
{
|
|
541
|
+
endpointId,
|
|
542
|
+
clusterId,
|
|
543
|
+
eventId,
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
eventFilters: eventMin ? [{ eventMin }] : undefined,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
values: eventData.flatMap(({ path: { endpointId, clusterId, eventId }, events }) =>
|
|
551
|
+
events.map(({ eventNumber, data }) => ({
|
|
552
|
+
eventId,
|
|
553
|
+
clusterId,
|
|
554
|
+
endpointId,
|
|
555
|
+
eventNumber,
|
|
556
|
+
value: data,
|
|
557
|
+
})),
|
|
558
|
+
),
|
|
559
|
+
status: eventStatus?.map(({ path: { endpointId, clusterId, eventId }, status, clusterStatus }) => ({
|
|
560
|
+
clusterId,
|
|
561
|
+
endpointId,
|
|
562
|
+
eventId,
|
|
563
|
+
status,
|
|
564
|
+
clusterStatus,
|
|
565
|
+
})),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async handleSubscribeAttribute(data: SubscribeAttributeRequest): Promise<SubscribeAttributeResponse> {
|
|
570
|
+
const { nodeId, endpointId, clusterId, attributeId, minInterval, maxInterval, changeListener } = data;
|
|
571
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
572
|
+
const updated = Observable<[void]>();
|
|
573
|
+
let ignoreData = true; // We ignore data coming in during initial seeding
|
|
574
|
+
const { attributeReports = [] } = await client.subscribeMultipleAttributesAndEvents({
|
|
575
|
+
attributes: [
|
|
576
|
+
{
|
|
577
|
+
endpointId,
|
|
578
|
+
clusterId,
|
|
579
|
+
attributeId,
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
minIntervalFloorSeconds: minInterval,
|
|
583
|
+
maxIntervalCeilingSeconds: maxInterval,
|
|
584
|
+
attributeListener: data => {
|
|
585
|
+
if (ignoreData) return;
|
|
586
|
+
changeListener({
|
|
587
|
+
attributeId: data.path.attributeId,
|
|
588
|
+
clusterId: data.path.clusterId,
|
|
589
|
+
endpointId: data.path.endpointId,
|
|
590
|
+
dataVersion: data.version,
|
|
591
|
+
value: data.value,
|
|
592
|
+
});
|
|
593
|
+
},
|
|
594
|
+
updateReceived: () => {
|
|
595
|
+
updated.emit();
|
|
596
|
+
},
|
|
597
|
+
keepSubscriptions: false,
|
|
598
|
+
});
|
|
599
|
+
ignoreData = false;
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
values: attributeReports.map(
|
|
603
|
+
({ path: { endpointId, clusterId, attributeId }, value, version: dataVersion }) => ({
|
|
604
|
+
attributeId,
|
|
605
|
+
clusterId,
|
|
606
|
+
endpointId,
|
|
607
|
+
dataVersion,
|
|
608
|
+
value,
|
|
609
|
+
}),
|
|
610
|
+
),
|
|
611
|
+
updated,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async handleSubscribeEvent(data: SubscribeEventRequest): Promise<SubscribeEventResponse> {
|
|
616
|
+
const { nodeId, endpointId, clusterId, eventId, minInterval, maxInterval, changeListener } = data;
|
|
617
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
618
|
+
const updated = Observable<[void]>();
|
|
619
|
+
let ignoreData = true; // We ignore data coming in during initial seeding
|
|
620
|
+
const { eventReports = [] } = await client.subscribeMultipleAttributesAndEvents({
|
|
621
|
+
events: [
|
|
622
|
+
{
|
|
623
|
+
endpointId,
|
|
624
|
+
clusterId,
|
|
625
|
+
eventId,
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
minIntervalFloorSeconds: minInterval,
|
|
629
|
+
maxIntervalCeilingSeconds: maxInterval,
|
|
630
|
+
eventListener: data => {
|
|
631
|
+
if (ignoreData) return;
|
|
632
|
+
data.events.forEach(event =>
|
|
633
|
+
changeListener({
|
|
634
|
+
eventId: data.path.eventId,
|
|
635
|
+
clusterId: data.path.clusterId,
|
|
636
|
+
endpointId: data.path.endpointId,
|
|
637
|
+
eventNumber: event.eventNumber,
|
|
638
|
+
value: event.data,
|
|
639
|
+
}),
|
|
640
|
+
);
|
|
641
|
+
},
|
|
642
|
+
updateReceived: () => {
|
|
643
|
+
updated.emit();
|
|
644
|
+
},
|
|
645
|
+
keepSubscriptions: false,
|
|
646
|
+
});
|
|
647
|
+
ignoreData = false;
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
values: eventReports.flatMap(({ path: { endpointId, clusterId, eventId }, events }) =>
|
|
651
|
+
events.map(({ eventNumber, data }) => ({
|
|
652
|
+
eventId,
|
|
653
|
+
clusterId,
|
|
654
|
+
endpointId,
|
|
655
|
+
eventNumber,
|
|
656
|
+
value: data,
|
|
657
|
+
})),
|
|
658
|
+
),
|
|
659
|
+
updated,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async handleWriteAttribute(data: WriteAttributeRequest): Promise<AttributeResponseStatus> {
|
|
664
|
+
const { nodeId, endpointId, clusterId, attributeId, value } = data;
|
|
665
|
+
|
|
666
|
+
const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
|
|
667
|
+
|
|
668
|
+
logger.info("Writing attribute", attributeId, "with value", value);
|
|
669
|
+
try {
|
|
670
|
+
await client.attributes[attributeId].set(value);
|
|
671
|
+
return {
|
|
672
|
+
attributeId,
|
|
673
|
+
clusterId,
|
|
674
|
+
endpointId,
|
|
675
|
+
status: 0,
|
|
676
|
+
};
|
|
677
|
+
} catch (error) {
|
|
678
|
+
StatusResponseError.accept(error);
|
|
679
|
+
return {
|
|
680
|
+
attributeId,
|
|
681
|
+
clusterId,
|
|
682
|
+
endpointId,
|
|
683
|
+
status: error.code,
|
|
684
|
+
clusterStatus: error.clusterCode,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async handleInvoke(data: InvokeRequest): Promise<any> {
|
|
690
|
+
const {
|
|
691
|
+
nodeId,
|
|
692
|
+
endpointId,
|
|
693
|
+
clusterId,
|
|
694
|
+
commandName,
|
|
695
|
+
timedInteractionTimeoutMs: timedRequestTimeoutMs,
|
|
696
|
+
interactionTimeoutMs,
|
|
697
|
+
} = data;
|
|
698
|
+
let { data: commandData } = data;
|
|
699
|
+
|
|
700
|
+
const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
|
|
701
|
+
|
|
702
|
+
if (!client[commandName] || !client.isCommandSupportedByName(commandName)) {
|
|
703
|
+
throw new Error("Command not existing");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (isObject(commandData)) {
|
|
707
|
+
if (Object.keys(commandData).length === 0) {
|
|
708
|
+
commandData = undefined;
|
|
709
|
+
} else {
|
|
710
|
+
const cluster = ClusterMap[client.name.toLowerCase()];
|
|
711
|
+
const model = cluster?.commands[commandName.toLowerCase()];
|
|
712
|
+
if (cluster && model) {
|
|
713
|
+
commandData = convertCommandDataToMatter(commandData, model, cluster.model);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return (client[commandName] as unknown as SignatureFromCommandSpec<Command<any, any, any>>)(commandData, {
|
|
718
|
+
timedRequestTimeout: Millis(timedRequestTimeoutMs),
|
|
719
|
+
expectedProcessingTime: interactionTimeoutMs !== undefined ? Millis(interactionTimeoutMs) : undefined,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** InvokeById minimalistic handler because only used for error testing */
|
|
724
|
+
async handleInvokeById(data: InvokeByIdRequest): Promise<void> {
|
|
725
|
+
const { nodeId, endpointId, clusterId, commandId, data: commandData, timedInteractionTimeoutMs } = data;
|
|
726
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
727
|
+
await client.invoke<Command<any, any, any>>({
|
|
728
|
+
endpointId,
|
|
729
|
+
clusterId: clusterId,
|
|
730
|
+
command: Command(commandId, TlvAny, 0x00, TlvNoResponse, {
|
|
731
|
+
timed: timedInteractionTimeoutMs !== undefined,
|
|
732
|
+
}),
|
|
733
|
+
request: commandData === undefined ? TlvVoid.encodeTlv() : TlvObject({}).encodeTlv(commandData as any),
|
|
734
|
+
asTimedRequest: timedInteractionTimeoutMs !== undefined,
|
|
735
|
+
timedRequestTimeout: Millis(timedInteractionTimeoutMs),
|
|
736
|
+
skipValidation: true,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async handleWriteAttributeById(data: WriteAttributeByIdRequest): Promise<void> {
|
|
741
|
+
const { nodeId, endpointId, clusterId, attributeId, value } = data;
|
|
742
|
+
|
|
743
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
744
|
+
|
|
745
|
+
logger.info("Writing attribute", attributeId, "with value", value);
|
|
746
|
+
|
|
747
|
+
let tlvValue: any;
|
|
748
|
+
|
|
749
|
+
if (value === null) {
|
|
750
|
+
tlvValue = TlvNullable(TlvBoolean).encodeTlv(value); // Boolean is just a placeholder here
|
|
751
|
+
} else if (value instanceof Uint8Array) {
|
|
752
|
+
tlvValue = TlvByteString.encodeTlv(value);
|
|
753
|
+
} else {
|
|
754
|
+
switch (typeof value) {
|
|
755
|
+
case "boolean":
|
|
756
|
+
tlvValue = TlvBoolean.encodeTlv(value);
|
|
757
|
+
break;
|
|
758
|
+
case "number":
|
|
759
|
+
tlvValue = TlvInt32.encodeTlv(value);
|
|
760
|
+
break;
|
|
761
|
+
case "bigint":
|
|
762
|
+
tlvValue = TlvUInt64.encodeTlv(value);
|
|
763
|
+
break;
|
|
764
|
+
case "string":
|
|
765
|
+
tlvValue = TlvString.encodeTlv(value);
|
|
766
|
+
break;
|
|
767
|
+
default:
|
|
768
|
+
throw new Error("Unsupported value type for Any encoding");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
await client.setAttribute({
|
|
773
|
+
attributeData: {
|
|
774
|
+
endpointId,
|
|
775
|
+
clusterId,
|
|
776
|
+
attribute: Attribute(attributeId, TlvAny),
|
|
777
|
+
value: tlvValue,
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
#determineCommissionOptions(data: CommissioningRequest): NodeCommissioningOptions {
|
|
783
|
+
let passcode: number | undefined = undefined;
|
|
784
|
+
let shortDiscriminator: number | undefined = undefined;
|
|
785
|
+
let longDiscriminator: number | undefined = undefined;
|
|
786
|
+
let productId: number | undefined = undefined;
|
|
787
|
+
let vendorId: VendorId | undefined = undefined;
|
|
788
|
+
let knownAddress: ServerAddress | undefined = undefined;
|
|
789
|
+
|
|
790
|
+
if ("manualCode" in data && data.manualCode.length > 0) {
|
|
791
|
+
const pairingCodeCodec = ManualPairingCodeCodec.decode(data.manualCode);
|
|
792
|
+
shortDiscriminator = pairingCodeCodec.shortDiscriminator;
|
|
793
|
+
longDiscriminator = undefined;
|
|
794
|
+
passcode = pairingCodeCodec.passcode;
|
|
795
|
+
} else if ("qrCode" in data && data.qrCode.length > 0) {
|
|
796
|
+
const pairingCodeCodec = QrPairingCodeCodec.decode(data.qrCode);
|
|
797
|
+
// TODO handle the case where multiple devices are included
|
|
798
|
+
longDiscriminator = pairingCodeCodec[0].discriminator;
|
|
799
|
+
shortDiscriminator = undefined;
|
|
800
|
+
passcode = pairingCodeCodec[0].passcode;
|
|
801
|
+
} else if ("passcode" in data) {
|
|
802
|
+
passcode = data.passcode;
|
|
803
|
+
// Check for discriminator-based discovery
|
|
804
|
+
if ("shortDiscriminator" in data) {
|
|
805
|
+
shortDiscriminator = data.shortDiscriminator;
|
|
806
|
+
} else if ("longDiscriminator" in data) {
|
|
807
|
+
longDiscriminator = data.longDiscriminator;
|
|
808
|
+
} else if ("vendorId" in data && "productId" in data) {
|
|
809
|
+
vendorId = VendorId(data.vendorId);
|
|
810
|
+
productId = data.productId;
|
|
811
|
+
}
|
|
812
|
+
// If none of the above, will discover any commissionable device
|
|
813
|
+
} else {
|
|
814
|
+
throw new Error("No pairing code provided");
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (data.knownAddress !== undefined) {
|
|
818
|
+
const { ip, port } = data.knownAddress;
|
|
819
|
+
knownAddress = {
|
|
820
|
+
type: "udp",
|
|
821
|
+
ip,
|
|
822
|
+
port,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (passcode == undefined) {
|
|
827
|
+
throw new Error("No passcode provided");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const { onNetworkOnly } = data;
|
|
831
|
+
return {
|
|
832
|
+
commissioning: {
|
|
833
|
+
nodeId: data.nodeId,
|
|
834
|
+
regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
|
|
835
|
+
regulatoryCountryCode: "XX",
|
|
836
|
+
},
|
|
837
|
+
discovery: {
|
|
838
|
+
knownAddress,
|
|
839
|
+
identifierData:
|
|
840
|
+
longDiscriminator !== undefined
|
|
841
|
+
? { longDiscriminator }
|
|
842
|
+
: shortDiscriminator !== undefined
|
|
843
|
+
? { shortDiscriminator }
|
|
844
|
+
: vendorId !== undefined
|
|
845
|
+
? { vendorId, productId }
|
|
846
|
+
: {},
|
|
847
|
+
discoveryCapabilities: {
|
|
848
|
+
ble: this.bleEnabled && !onNetworkOnly,
|
|
849
|
+
onIpNetwork: true,
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
passcode,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async commissionNode(data: CommissioningRequest): Promise<CommissioningResponse> {
|
|
857
|
+
const nodeId = await this.#controller.commissionNode(this.#determineCommissionOptions(data), {
|
|
858
|
+
connectNodeAfterCommissioning: true,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
await this.#registerNode(nodeId);
|
|
862
|
+
|
|
863
|
+
this.events.nodeAdded.emit(nodeId);
|
|
864
|
+
return { nodeId };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
getCommissionerNodeId() {
|
|
868
|
+
return this.#controller.nodeId;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async getCommissionerFabricData(): Promise<{ fabricId: FabricId; compressedFabricId: bigint }> {
|
|
872
|
+
const { fabricId, globalId } = this.#controller.fabric;
|
|
873
|
+
return {
|
|
874
|
+
fabricId,
|
|
875
|
+
compressedFabricId: globalId,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Discover commissionable devices */
|
|
880
|
+
async handleDiscovery({ findBy }: DiscoveryRequest): Promise<DiscoveryResponse> {
|
|
881
|
+
const result = await this.#controller.discoverCommissionableDevices(
|
|
882
|
+
findBy ?? {},
|
|
883
|
+
{ onIpNetwork: true },
|
|
884
|
+
undefined,
|
|
885
|
+
Seconds(3), // Just check for 3 sec
|
|
886
|
+
);
|
|
887
|
+
logger.info("Discovered result", result);
|
|
888
|
+
// Chip is not removing old discoveries when being stopped, so we still have old and new devices in the result
|
|
889
|
+
// but the expectation is that it was reset and only new devices are in the result
|
|
890
|
+
const latestDiscovery = result[result.length - 1];
|
|
891
|
+
if (latestDiscovery === undefined) {
|
|
892
|
+
return [];
|
|
893
|
+
}
|
|
894
|
+
return [latestDiscovery].map(({ DT, DN, CM, D, RI, PH, PI, T, VP, deviceIdentifier, addresses, SII, SAI }) => {
|
|
895
|
+
const { tcpClient: supportsTcpClient, tcpServer: supportsTcpServer } = SupportedTransportsSchema.decode(
|
|
896
|
+
T ?? 0,
|
|
897
|
+
);
|
|
898
|
+
const vendorId = VP === undefined ? -1 : VP.includes("+") ? parseInt(VP.split("+")[0]) : parseInt(VP);
|
|
899
|
+
const productId = VP === undefined ? -1 : VP.includes("+") ? parseInt(VP.split("+")[1]) : -1;
|
|
900
|
+
const port = addresses.length ? (addresses[0] as ServerAddressUdp).port : 0;
|
|
901
|
+
const numIPs = addresses.length;
|
|
902
|
+
return {
|
|
903
|
+
commissioningMode: CM,
|
|
904
|
+
deviceName: DN ?? "",
|
|
905
|
+
deviceType: DT ?? 0,
|
|
906
|
+
hostName: "000000000000", // Right now we do not return real hostname, only used internally
|
|
907
|
+
instanceName: deviceIdentifier,
|
|
908
|
+
longDiscriminator: D,
|
|
909
|
+
numIPs,
|
|
910
|
+
pairingHint: PH ?? -1,
|
|
911
|
+
pairingInstruction: PI ?? "",
|
|
912
|
+
port,
|
|
913
|
+
productId,
|
|
914
|
+
rotatingId: RI ?? "",
|
|
915
|
+
rotatingIdLen: RI?.length ?? 0,
|
|
916
|
+
shortDiscriminator: (D >> 8) & 0x0f,
|
|
917
|
+
vendorId,
|
|
918
|
+
supportsTcpServer,
|
|
919
|
+
supportsTcpClient,
|
|
920
|
+
addresses: (addresses.filter(({ type }) => type === "udp") as ServerAddressUdp[]).map(({ ip }) => ip),
|
|
921
|
+
mrpSessionIdleInterval: SII,
|
|
922
|
+
mrpSessionActiveInterval: SAI,
|
|
923
|
+
};
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async getNodeIpAddresses(nodeId: NodeId, preferCache = true) {
|
|
928
|
+
const node = this.#nodes.get(nodeId);
|
|
929
|
+
const addresses = new Set<string>();
|
|
930
|
+
const generalDiag = node.getRootClusterClient(GeneralDiagnosticsCluster);
|
|
931
|
+
if (generalDiag) {
|
|
932
|
+
try {
|
|
933
|
+
const networkInterfaces = await generalDiag.getNetworkInterfacesAttribute(preferCache ? true : true);
|
|
934
|
+
if (networkInterfaces) {
|
|
935
|
+
const interfaces = networkInterfaces.filter(({ isOperational }) => isOperational);
|
|
936
|
+
if (interfaces.length) {
|
|
937
|
+
logger.info(`Found ${interfaces.length} operational network interfaces`, interfaces);
|
|
938
|
+
interfaces.forEach(({ iPv4Addresses, iPv6Addresses }) => {
|
|
939
|
+
iPv4Addresses.forEach(ip => addresses.add(ipv4BytesToString(Bytes.of(ip))));
|
|
940
|
+
iPv6Addresses.forEach(ip => addresses.add(ipv6BytesToString(Bytes.of(ip))));
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
} catch (e) {
|
|
945
|
+
logger.info(`Failed to get network interfaces: ${e}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return Array.from(addresses.values());
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Ping a node on all its known IP addresses.
|
|
953
|
+
* @param nodeId The node ID to ping
|
|
954
|
+
* @param attempts Number of ping attempts per IP (default: 1)
|
|
955
|
+
* @returns A record of IP addresses to ping success status
|
|
956
|
+
*/
|
|
957
|
+
async pingNode(nodeId: NodeId, attempts = 1): Promise<NodePingResult> {
|
|
958
|
+
const node = this.#nodes.get(nodeId);
|
|
959
|
+
|
|
960
|
+
const result: NodePingResult = {};
|
|
961
|
+
|
|
962
|
+
// Get all IP addresses for the node (fresh lookup, not cached)
|
|
963
|
+
const ipAddresses = await this.getNodeIpAddresses(nodeId, false);
|
|
964
|
+
|
|
965
|
+
if (ipAddresses.length === 0) {
|
|
966
|
+
logger.info(`No IP addresses found for node ${nodeId}`);
|
|
967
|
+
return result;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
logger.info(`Pinging node ${nodeId} on ${ipAddresses.length} addresses:`, ipAddresses);
|
|
971
|
+
|
|
972
|
+
// Ping all addresses in parallel
|
|
973
|
+
const pingPromises = ipAddresses.map(async ip => {
|
|
974
|
+
const cleanIp = ip.includes("%") ? ip.split("%")[0] : ip;
|
|
975
|
+
logger.debug(`Pinging ${cleanIp}`);
|
|
976
|
+
const success = await pingIp(ip, 10, attempts);
|
|
977
|
+
result[ip] = success;
|
|
978
|
+
logger.debug(`Ping result for ${cleanIp}: ${success}`);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
await Promise.all(pingPromises);
|
|
982
|
+
|
|
983
|
+
// If the node is connected, treat the connection as valid
|
|
984
|
+
if (node.isConnected) {
|
|
985
|
+
// Find any successful ping or mark the connection as reachable
|
|
986
|
+
const anySuccess = Object.values(result).some(v => v);
|
|
987
|
+
if (!anySuccess && ipAddresses.length > 0) {
|
|
988
|
+
// Node is connected, but no pings succeeded - this can happen
|
|
989
|
+
// with Thread devices or certain network configurations
|
|
990
|
+
logger.info(`Node ${nodeId} is connected but no pings succeeded`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
return result;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async decommissionNode(nodeId: NodeId) {
|
|
998
|
+
const node = this.#nodes.has(nodeId) ? this.#nodes.get(nodeId) : undefined;
|
|
999
|
+
await this.#controller.removeNode(nodeId, !!node?.isConnected);
|
|
1000
|
+
// Remove node from storage (also clears attribute cache)
|
|
1001
|
+
this.#nodes.delete(nodeId);
|
|
1002
|
+
// Emit nodeDecommissioned event after successful removal
|
|
1003
|
+
this.events.nodeDecommissioned.emit(nodeId);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async openCommissioningWindow(data: OpenCommissioningWindowRequest): Promise<OpenCommissioningWindowResponse> {
|
|
1007
|
+
const { nodeId, timeout } = data;
|
|
1008
|
+
const node = this.#nodes.get(nodeId);
|
|
1009
|
+
const { manualPairingCode, qrPairingCode } = await node.openEnhancedCommissioningWindow(timeout);
|
|
1010
|
+
return { manualCode: manualPairingCode, qrCode: qrPairingCode };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async getFabrics(nodeId: NodeId) {
|
|
1014
|
+
const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), OperationalCredentials.Cluster);
|
|
1015
|
+
|
|
1016
|
+
return (await client.getFabricsAttribute(true, false)).map(({ fabricId, fabricIndex, vendorId, label }) => ({
|
|
1017
|
+
fabricId,
|
|
1018
|
+
vendorId,
|
|
1019
|
+
fabricIndex,
|
|
1020
|
+
label,
|
|
1021
|
+
}));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
removeFabric(nodeId: NodeId, fabricIndex: FabricIndex) {
|
|
1025
|
+
const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), OperationalCredentials.Cluster);
|
|
1026
|
+
|
|
1027
|
+
return client.removeFabric({ fabricIndex });
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Set Access Control List entries on a node.
|
|
1032
|
+
* Writes to the ACL attribute on the AccessControl cluster (endpoint 0).
|
|
1033
|
+
*/
|
|
1034
|
+
async setAclEntry(nodeId: NodeId, entries: AccessControlEntry[]): Promise<AttributeWriteResult[] | null> {
|
|
1035
|
+
const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), AccessControl.Cluster);
|
|
1036
|
+
|
|
1037
|
+
// Convert from WebSocket format (snake_case) to Matter.js format (camelCase)
|
|
1038
|
+
const aclEntries: AccessControl.AccessControlEntry[] = entries.map(entry => ({
|
|
1039
|
+
privilege: entry.privilege as AccessControl.AccessControlEntryPrivilege,
|
|
1040
|
+
authMode: entry.auth_mode as AccessControl.AccessControlEntryAuthMode,
|
|
1041
|
+
subjects: entry.subjects?.map(s => NodeId(BigInt(s))) ?? null,
|
|
1042
|
+
targets:
|
|
1043
|
+
entry.targets?.map((t: AccessControlTarget) => ({
|
|
1044
|
+
cluster: t.cluster !== null ? ClusterId(t.cluster) : null,
|
|
1045
|
+
endpoint: t.endpoint !== null ? EndpointNumber(t.endpoint) : null,
|
|
1046
|
+
deviceType: t.device_type !== null ? DeviceTypeId(t.device_type) : null,
|
|
1047
|
+
})) ?? null,
|
|
1048
|
+
fabricIndex: FabricIndex.OMIT_FABRIC,
|
|
1049
|
+
}));
|
|
1050
|
+
|
|
1051
|
+
logger.info("Setting ACL entries", aclEntries);
|
|
1052
|
+
|
|
1053
|
+
try {
|
|
1054
|
+
await client.setAclAttribute(aclEntries);
|
|
1055
|
+
return [
|
|
1056
|
+
{
|
|
1057
|
+
path: {
|
|
1058
|
+
endpoint_id: 0,
|
|
1059
|
+
cluster_id: AccessControl.Cluster.id,
|
|
1060
|
+
attribute_id: 0, // ACL attribute ID
|
|
1061
|
+
},
|
|
1062
|
+
status: 0,
|
|
1063
|
+
},
|
|
1064
|
+
];
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
StatusResponseError.accept(error);
|
|
1067
|
+
return [
|
|
1068
|
+
{
|
|
1069
|
+
path: {
|
|
1070
|
+
endpoint_id: 0,
|
|
1071
|
+
cluster_id: AccessControl.Cluster.id,
|
|
1072
|
+
attribute_id: 0,
|
|
1073
|
+
},
|
|
1074
|
+
status: error.code,
|
|
1075
|
+
},
|
|
1076
|
+
];
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Set bindings on a specific endpoint of a node.
|
|
1082
|
+
* Writes to the Binding attribute on the Binding cluster.
|
|
1083
|
+
*/
|
|
1084
|
+
async setNodeBinding(
|
|
1085
|
+
nodeId: NodeId,
|
|
1086
|
+
endpointId: EndpointNumber,
|
|
1087
|
+
bindings: BindingTarget[],
|
|
1088
|
+
): Promise<AttributeWriteResult[] | null> {
|
|
1089
|
+
const client = this.#nodes.clusterClientFor(nodeId, endpointId, Binding.Cluster);
|
|
1090
|
+
|
|
1091
|
+
// Convert from WebSocket format to Matter.js format
|
|
1092
|
+
const bindingEntries: Binding.Target[] = bindings.map(binding => ({
|
|
1093
|
+
node: binding.node !== null ? NodeId(binding.node) : undefined,
|
|
1094
|
+
group: binding.group !== null ? GroupId(binding.group) : undefined,
|
|
1095
|
+
endpoint: binding.endpoint !== null ? EndpointNumber(binding.endpoint) : undefined,
|
|
1096
|
+
cluster: binding.cluster !== null ? ClusterId(binding.cluster) : undefined,
|
|
1097
|
+
fabricIndex: FabricIndex.OMIT_FABRIC,
|
|
1098
|
+
}));
|
|
1099
|
+
|
|
1100
|
+
logger.info("Setting bindings on endpoint", endpointId, bindingEntries);
|
|
1101
|
+
|
|
1102
|
+
try {
|
|
1103
|
+
await client.attributes.binding.set(bindingEntries);
|
|
1104
|
+
return [
|
|
1105
|
+
{
|
|
1106
|
+
path: {
|
|
1107
|
+
endpoint_id: endpointId,
|
|
1108
|
+
cluster_id: Binding.Cluster.id,
|
|
1109
|
+
attribute_id: 0, // Binding attribute ID
|
|
1110
|
+
},
|
|
1111
|
+
status: 0,
|
|
1112
|
+
},
|
|
1113
|
+
];
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
StatusResponseError.accept(error);
|
|
1116
|
+
return [
|
|
1117
|
+
{
|
|
1118
|
+
path: {
|
|
1119
|
+
endpoint_id: endpointId,
|
|
1120
|
+
cluster_id: Binding.Cluster.id,
|
|
1121
|
+
attribute_id: 0,
|
|
1122
|
+
},
|
|
1123
|
+
status: error.code,
|
|
1124
|
+
},
|
|
1125
|
+
];
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Check if a software update is available for a node.
|
|
1131
|
+
* First checks the cached updates from OTA events, then queries the DCL if not found.
|
|
1132
|
+
*/
|
|
1133
|
+
async checkNodeUpdate(nodeId: NodeId): Promise<MatterSoftwareVersion | null> {
|
|
1134
|
+
if (!this.#otaEnabled) {
|
|
1135
|
+
throw new Error("OTA is disabled.");
|
|
1136
|
+
}
|
|
1137
|
+
// First check if we have a cached update from the updateAvailable event
|
|
1138
|
+
const cachedUpdate = this.#availableUpdates.get(nodeId);
|
|
1139
|
+
if (cachedUpdate) {
|
|
1140
|
+
return this.#convertToMatterSoftwareVersion(cachedUpdate);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// No cached update, query the OTA provider
|
|
1144
|
+
const node = this.#nodes.get(nodeId);
|
|
1145
|
+
|
|
1146
|
+
try {
|
|
1147
|
+
const otaProvider = this.#controller.otaProvider;
|
|
1148
|
+
if (!otaProvider) {
|
|
1149
|
+
logger.info("OTA provider not available");
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Query OTA provider for updates using dynamic behavior access
|
|
1154
|
+
const updatesAvailable = await otaProvider.act(agent =>
|
|
1155
|
+
agent.get(SoftwareUpdateManager).queryUpdates({
|
|
1156
|
+
peerToCheck: node.node,
|
|
1157
|
+
includeStoredUpdates: true,
|
|
1158
|
+
}),
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
// Find update for this specific node
|
|
1162
|
+
const peerAddress = this.#controller.fabric.addressOf(nodeId);
|
|
1163
|
+
const nodeUpdate = updatesAvailable.find(({ peerAddress: updateAddress }) =>
|
|
1164
|
+
PeerAddress.is(peerAddress, updateAddress),
|
|
1165
|
+
);
|
|
1166
|
+
|
|
1167
|
+
if (nodeUpdate) {
|
|
1168
|
+
const { info } = nodeUpdate;
|
|
1169
|
+
this.#availableUpdates.set(nodeId, info);
|
|
1170
|
+
return this.#convertToMatterSoftwareVersion(info);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
return null;
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
logger.warn(`Failed to check for updates for node ${nodeId}:`, error);
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Trigger a software update for a node.
|
|
1182
|
+
* @param nodeId The node to update
|
|
1183
|
+
* @param softwareVersion The target software version to update to
|
|
1184
|
+
*/
|
|
1185
|
+
async updateNode(nodeId: NodeId, softwareVersion: number): Promise<MatterSoftwareVersion | null> {
|
|
1186
|
+
if (!this.#otaEnabled) {
|
|
1187
|
+
throw new Error("OTA is disabled.");
|
|
1188
|
+
}
|
|
1189
|
+
if (!this.#nodes.has(nodeId)) {
|
|
1190
|
+
throw new Error(`Node ${nodeId} not found`);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
try {
|
|
1194
|
+
const otaProvider = this.#controller.otaProvider;
|
|
1195
|
+
if (!otaProvider) {
|
|
1196
|
+
throw new Error("OTA provider not available");
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Get the cached update info or query for it
|
|
1200
|
+
let updateInfo = this.#availableUpdates.get(nodeId);
|
|
1201
|
+
if (!updateInfo) {
|
|
1202
|
+
// Try to get update info by querying
|
|
1203
|
+
const result = await this.checkNodeUpdate(nodeId);
|
|
1204
|
+
if (!result) {
|
|
1205
|
+
throw new Error("No update available for this node");
|
|
1206
|
+
}
|
|
1207
|
+
updateInfo = this.#availableUpdates.get(nodeId);
|
|
1208
|
+
if (!updateInfo) {
|
|
1209
|
+
throw new Error("Failed to get update info");
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
logger.info(`Starting update for node ${nodeId} to version ${softwareVersion}`);
|
|
1214
|
+
|
|
1215
|
+
// Trigger the update using forceUpdate via dynamic behavior access
|
|
1216
|
+
await otaProvider.act(agent =>
|
|
1217
|
+
agent
|
|
1218
|
+
.get(SoftwareUpdateManager)
|
|
1219
|
+
.forceUpdate(
|
|
1220
|
+
this.#controller.fabric.addressOf(nodeId),
|
|
1221
|
+
updateInfo.vendorId,
|
|
1222
|
+
updateInfo.productId,
|
|
1223
|
+
softwareVersion,
|
|
1224
|
+
),
|
|
1225
|
+
);
|
|
1226
|
+
|
|
1227
|
+
// Return the update info
|
|
1228
|
+
return this.#convertToMatterSoftwareVersion(updateInfo);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
logger.error(`Failed to update node ${nodeId}:`, error);
|
|
1231
|
+
throw error;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Convert SoftwareUpdateInfo to MatterSoftwareVersion format for WebSocket API.
|
|
1237
|
+
*/
|
|
1238
|
+
#convertToMatterSoftwareVersion(updateInfo: SoftwareUpdateInfo): MatterSoftwareVersion {
|
|
1239
|
+
const { vendorId, productId, softwareVersion, softwareVersionString, releaseNotesUrl, source } = updateInfo;
|
|
1240
|
+
return {
|
|
1241
|
+
vid: vendorId,
|
|
1242
|
+
pid: productId,
|
|
1243
|
+
software_version: softwareVersion,
|
|
1244
|
+
software_version_string: softwareVersionString,
|
|
1245
|
+
min_applicable_software_version: 0, // Not available from SoftwareUpdateInfo
|
|
1246
|
+
max_applicable_software_version: softwareVersion - 1,
|
|
1247
|
+
release_notes_url: releaseNotesUrl,
|
|
1248
|
+
update_source:
|
|
1249
|
+
source === "dcl-prod"
|
|
1250
|
+
? UpdateSource.MAIN_NET_DCL
|
|
1251
|
+
: source === "dcl-test"
|
|
1252
|
+
? UpdateSource.TEST_NET_DCL
|
|
1253
|
+
: UpdateSource.LOCAL,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
}
|