@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,1015 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025-2026 Open Home Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { AsyncObservable, isObject } from "@matter/general";
|
|
7
|
+
import {
|
|
8
|
+
Bytes,
|
|
9
|
+
ClusterBehavior,
|
|
10
|
+
FabricIndex,
|
|
11
|
+
ipv4BytesToString,
|
|
12
|
+
ipv6BytesToString,
|
|
13
|
+
Logger,
|
|
14
|
+
Millis,
|
|
15
|
+
NodeId,
|
|
16
|
+
Observable,
|
|
17
|
+
Seconds,
|
|
18
|
+
SoftwareUpdateManager
|
|
19
|
+
} from "@matter/main";
|
|
20
|
+
import {
|
|
21
|
+
AccessControl,
|
|
22
|
+
Binding,
|
|
23
|
+
GeneralCommissioning,
|
|
24
|
+
GeneralDiagnosticsCluster,
|
|
25
|
+
OperationalCredentials
|
|
26
|
+
} from "@matter/main/clusters";
|
|
27
|
+
import {
|
|
28
|
+
PeerAddress,
|
|
29
|
+
Read,
|
|
30
|
+
SupportedTransportsSchema
|
|
31
|
+
} from "@matter/main/protocol";
|
|
32
|
+
import {
|
|
33
|
+
Attribute,
|
|
34
|
+
ClusterId,
|
|
35
|
+
Command,
|
|
36
|
+
DeviceTypeId,
|
|
37
|
+
EndpointNumber,
|
|
38
|
+
getClusterById,
|
|
39
|
+
GroupId,
|
|
40
|
+
ManualPairingCodeCodec,
|
|
41
|
+
QrPairingCodeCodec,
|
|
42
|
+
StatusResponseError,
|
|
43
|
+
TlvAny,
|
|
44
|
+
TlvBoolean,
|
|
45
|
+
TlvByteString,
|
|
46
|
+
TlvInt32,
|
|
47
|
+
TlvNoResponse,
|
|
48
|
+
TlvNullable,
|
|
49
|
+
TlvObject,
|
|
50
|
+
TlvString,
|
|
51
|
+
TlvUInt64,
|
|
52
|
+
TlvVoid,
|
|
53
|
+
VendorId
|
|
54
|
+
} from "@matter/main/types";
|
|
55
|
+
import { NodeStates } from "@project-chip/matter.js/device";
|
|
56
|
+
import { ClusterMap } from "../model/ModelMapper.js";
|
|
57
|
+
import {
|
|
58
|
+
buildAttributePath,
|
|
59
|
+
convertCommandDataToMatter,
|
|
60
|
+
convertMatterToWebSocketTagBased,
|
|
61
|
+
getDateAsString,
|
|
62
|
+
splitAttributePath
|
|
63
|
+
} from "../server/Converters.js";
|
|
64
|
+
import {
|
|
65
|
+
UpdateSource
|
|
66
|
+
} from "../types/WebSocketMessageTypes.js";
|
|
67
|
+
import { pingIp } from "../util/network.js";
|
|
68
|
+
import { Nodes } from "./Nodes.js";
|
|
69
|
+
const logger = Logger.get("ControllerCommandHandler");
|
|
70
|
+
class ControllerCommandHandler {
|
|
71
|
+
#controller;
|
|
72
|
+
#started = false;
|
|
73
|
+
#connected = false;
|
|
74
|
+
#bleEnabled = false;
|
|
75
|
+
#otaEnabled = false;
|
|
76
|
+
/** Node management and attribute cache */
|
|
77
|
+
#nodes = new Nodes();
|
|
78
|
+
/** Cache of available updates keyed by nodeId */
|
|
79
|
+
#availableUpdates = /* @__PURE__ */ new Map();
|
|
80
|
+
events = {
|
|
81
|
+
started: new AsyncObservable(),
|
|
82
|
+
attributeChanged: new Observable(),
|
|
83
|
+
eventChanged: new Observable(),
|
|
84
|
+
nodeAdded: new Observable(),
|
|
85
|
+
nodeStateChanged: new Observable(),
|
|
86
|
+
nodeStructureChanged: new Observable(),
|
|
87
|
+
nodeDecommissioned: new Observable(),
|
|
88
|
+
nodeEndpointAdded: new Observable(),
|
|
89
|
+
nodeEndpointRemoved: new Observable()
|
|
90
|
+
};
|
|
91
|
+
constructor(controllerInstance, bleEnabled, otaEnabled) {
|
|
92
|
+
this.#controller = controllerInstance;
|
|
93
|
+
this.#bleEnabled = bleEnabled;
|
|
94
|
+
this.#otaEnabled = otaEnabled;
|
|
95
|
+
}
|
|
96
|
+
get started() {
|
|
97
|
+
return this.#started;
|
|
98
|
+
}
|
|
99
|
+
get bleEnabled() {
|
|
100
|
+
return this.#bleEnabled;
|
|
101
|
+
}
|
|
102
|
+
async start() {
|
|
103
|
+
if (this.#started) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.#started = true;
|
|
107
|
+
await this.#controller.start();
|
|
108
|
+
logger.info(`Controller started`);
|
|
109
|
+
if (!this.#bleEnabled) {
|
|
110
|
+
await this.#setupOtaEventHandlers();
|
|
111
|
+
}
|
|
112
|
+
await this.events.started.emit();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Set up event handlers for OTA update notifications from the SoftwareUpdateManager.
|
|
116
|
+
*/
|
|
117
|
+
async #setupOtaEventHandlers() {
|
|
118
|
+
if (!this.#otaEnabled) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const otaProvider = this.#controller.otaProvider;
|
|
123
|
+
if (!otaProvider) {
|
|
124
|
+
logger.info("OTA provider not available");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const softwareUpdateManagerEvents = await otaProvider.act((agent) => agent.get(SoftwareUpdateManager).events);
|
|
128
|
+
if (softwareUpdateManagerEvents === void 0) {
|
|
129
|
+
logger.info("SoftwareUpdateManager not available");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
softwareUpdateManagerEvents.updateAvailable.on(
|
|
133
|
+
(peerAddress, updateDetails) => {
|
|
134
|
+
logger.info(`Update available for node ${peerAddress.nodeId}:`, updateDetails);
|
|
135
|
+
this.#availableUpdates.set(peerAddress.nodeId, updateDetails);
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
softwareUpdateManagerEvents.updateDone.on((peerAddress) => {
|
|
139
|
+
logger.info(`Update done for node ${peerAddress.nodeId}`);
|
|
140
|
+
this.#availableUpdates.delete(peerAddress.nodeId);
|
|
141
|
+
});
|
|
142
|
+
logger.info("OTA event handlers registered");
|
|
143
|
+
} catch (error) {
|
|
144
|
+
logger.warn("Failed to setup OTA event handlers:", error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
close() {
|
|
148
|
+
if (!this.#started) return;
|
|
149
|
+
return this.#controller.close();
|
|
150
|
+
}
|
|
151
|
+
async #registerNode(nodeId) {
|
|
152
|
+
const node = await this.#controller.getNode(nodeId);
|
|
153
|
+
const attributeCache = this.#nodes.attributeCache;
|
|
154
|
+
node.events.attributeChanged.on((data) => {
|
|
155
|
+
attributeCache.updateAttribute(nodeId, data);
|
|
156
|
+
this.events.attributeChanged.emit(nodeId, data);
|
|
157
|
+
});
|
|
158
|
+
node.events.eventTriggered.on((data) => this.events.eventChanged.emit(nodeId, data));
|
|
159
|
+
node.events.stateChanged.on((state) => {
|
|
160
|
+
if (state === NodeStates.Connected) {
|
|
161
|
+
attributeCache.update(node);
|
|
162
|
+
}
|
|
163
|
+
this.events.nodeStateChanged.emit(nodeId, state);
|
|
164
|
+
});
|
|
165
|
+
node.events.structureChanged.on(() => {
|
|
166
|
+
if (node.isConnected) {
|
|
167
|
+
attributeCache.update(node);
|
|
168
|
+
}
|
|
169
|
+
this.events.nodeStructureChanged.emit(nodeId);
|
|
170
|
+
});
|
|
171
|
+
node.events.decommissioned.on(() => this.events.nodeDecommissioned.emit(nodeId));
|
|
172
|
+
node.events.nodeEndpointAdded.on((endpointId) => this.events.nodeEndpointAdded.emit(nodeId, endpointId));
|
|
173
|
+
node.events.nodeEndpointRemoved.on((endpointId) => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
|
|
174
|
+
this.#nodes.set(nodeId, node);
|
|
175
|
+
if (node.initialized) {
|
|
176
|
+
attributeCache.add(node);
|
|
177
|
+
}
|
|
178
|
+
return node;
|
|
179
|
+
}
|
|
180
|
+
async connect() {
|
|
181
|
+
if (this.#connected) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.#connected = true;
|
|
185
|
+
await this.start();
|
|
186
|
+
const nodes = this.#controller.getCommissionedNodes();
|
|
187
|
+
logger.info(`Found ${nodes.length} nodes: ${nodes.map((nodeId) => nodeId.toString()).join(", ")}`);
|
|
188
|
+
for (const nodeId of nodes) {
|
|
189
|
+
try {
|
|
190
|
+
logger.info(`Initializing node "${nodeId}" ...`);
|
|
191
|
+
const node = await this.#registerNode(nodeId);
|
|
192
|
+
node.connect({
|
|
193
|
+
subscribeMinIntervalFloorSeconds: 1,
|
|
194
|
+
subscribeMaxIntervalCeilingSeconds: void 0
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logger.warn(`Failed to connect to node "${nodeId}":`, error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
getNodeIds() {
|
|
202
|
+
return this.#nodes.getIds();
|
|
203
|
+
}
|
|
204
|
+
hasNode(nodeId) {
|
|
205
|
+
return this.#nodes.has(nodeId);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Alias for decommissionNode to match NodeCommandHandler interface.
|
|
209
|
+
*/
|
|
210
|
+
removeNode(nodeId) {
|
|
211
|
+
return this.decommissionNode(nodeId);
|
|
212
|
+
}
|
|
213
|
+
async interviewNode(nodeId) {
|
|
214
|
+
const node = this.#nodes.get(nodeId);
|
|
215
|
+
logger.info(`Interview requested for node ${nodeId} - do a complete read`);
|
|
216
|
+
const read = {
|
|
217
|
+
...Read({
|
|
218
|
+
fabricFilter: true,
|
|
219
|
+
attributes: [{}]
|
|
220
|
+
}),
|
|
221
|
+
includeKnownVersions: true
|
|
222
|
+
// do not send DataVersionFilters, so we do a new clean read
|
|
223
|
+
};
|
|
224
|
+
for await (const _chunk of node.node.interaction.read(read)) ;
|
|
225
|
+
this.events.nodeStateChanged.emit(nodeId, node.connectionState);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Get full node details in WebSocket API format.
|
|
229
|
+
* @param nodeId The node ID
|
|
230
|
+
* @param lastInterviewDate Optional last interview date (tracked externally)
|
|
231
|
+
*/
|
|
232
|
+
async getNodeDetails(nodeId, lastInterviewDate) {
|
|
233
|
+
const node = this.#nodes.get(nodeId);
|
|
234
|
+
const attributeCache = this.#nodes.attributeCache;
|
|
235
|
+
let isBridge = false;
|
|
236
|
+
if (!attributeCache.has(nodeId)) {
|
|
237
|
+
attributeCache.add(node);
|
|
238
|
+
}
|
|
239
|
+
const attributes = attributeCache.get(nodeId) ?? {};
|
|
240
|
+
const endpoint1DeviceTypes = attributes["1/29/0"];
|
|
241
|
+
if (Array.isArray(endpoint1DeviceTypes)) {
|
|
242
|
+
isBridge = endpoint1DeviceTypes.some((entry) => entry["0"] === 14);
|
|
243
|
+
}
|
|
244
|
+
const commissionedAt = node.state.commissioning.commissionedAt;
|
|
245
|
+
const dateCommissioned = commissionedAt !== void 0 ? new Date(commissionedAt) : /* @__PURE__ */ new Date();
|
|
246
|
+
return {
|
|
247
|
+
node_id: node.nodeId,
|
|
248
|
+
date_commissioned: getDateAsString(dateCommissioned),
|
|
249
|
+
last_interview: getDateAsString(lastInterviewDate ?? /* @__PURE__ */ new Date()),
|
|
250
|
+
interview_version: 6,
|
|
251
|
+
available: node.isConnected,
|
|
252
|
+
is_bridge: isBridge,
|
|
253
|
+
attributes,
|
|
254
|
+
attribute_subscriptions: []
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Read multiple attributes from a node by path strings.
|
|
259
|
+
* Handles wildcards in paths.
|
|
260
|
+
*/
|
|
261
|
+
async handleReadAttributes(nodeId, attributePaths, fabricFiltered = false) {
|
|
262
|
+
const node = this.#nodes.get(nodeId);
|
|
263
|
+
const result = {};
|
|
264
|
+
const hasWildcards = attributePaths.some((path) => path.includes("*"));
|
|
265
|
+
let allAttributes;
|
|
266
|
+
if (hasWildcards) {
|
|
267
|
+
if (!node.initialized) {
|
|
268
|
+
throw new Error(`Node ${nodeId} not ready`);
|
|
269
|
+
}
|
|
270
|
+
const rootEndpoint = node.getRootEndpoint();
|
|
271
|
+
if (rootEndpoint === void 0) {
|
|
272
|
+
throw new Error(`Node ${nodeId} not ready`);
|
|
273
|
+
}
|
|
274
|
+
allAttributes = {};
|
|
275
|
+
this.#collectAttributesFromEndpoint(rootEndpoint, allAttributes);
|
|
276
|
+
}
|
|
277
|
+
for (const path of attributePaths) {
|
|
278
|
+
const { endpointId, clusterId, attributeId } = splitAttributePath(path);
|
|
279
|
+
if (path.includes("*") && allAttributes !== void 0) {
|
|
280
|
+
for (const [attrPath, value] of Object.entries(allAttributes)) {
|
|
281
|
+
const parts = attrPath.split("/").map(Number);
|
|
282
|
+
if ((endpointId === void 0 || parts[0] === endpointId) && (clusterId === void 0 || parts[1] === clusterId) && (attributeId === void 0 || parts[2] === attributeId)) {
|
|
283
|
+
result[attrPath] = value;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const { values, status } = await this.handleReadAttribute({
|
|
289
|
+
nodeId,
|
|
290
|
+
endpointId,
|
|
291
|
+
clusterId,
|
|
292
|
+
attributeId,
|
|
293
|
+
fabricFiltered
|
|
294
|
+
});
|
|
295
|
+
if (values.length) {
|
|
296
|
+
for (const valueData of values) {
|
|
297
|
+
const { pathStr, value } = this.#convertAttributeToWebSocket(
|
|
298
|
+
{
|
|
299
|
+
endpointId: EndpointNumber(valueData.endpointId),
|
|
300
|
+
clusterId: ClusterId(valueData.clusterId),
|
|
301
|
+
attributeId: valueData.attributeId
|
|
302
|
+
},
|
|
303
|
+
valueData.value
|
|
304
|
+
);
|
|
305
|
+
result[pathStr] = value;
|
|
306
|
+
}
|
|
307
|
+
} else if (status && status.length > 0) {
|
|
308
|
+
logger.warn(`Failed to read attribute ${path}: status=${JSON.stringify(status)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Collect all attributes from an endpoint and its children into WebSocket format.
|
|
315
|
+
*/
|
|
316
|
+
#collectAttributesFromEndpoint(endpoint, attributesData) {
|
|
317
|
+
const endpointId = endpoint.number;
|
|
318
|
+
for (const behavior of endpoint.endpoint.behaviors.active) {
|
|
319
|
+
if (!ClusterBehavior.is(behavior)) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const cluster = behavior.cluster;
|
|
323
|
+
const clusterId = cluster.id;
|
|
324
|
+
const clusterData = ClusterMap[cluster.name.toLowerCase()];
|
|
325
|
+
const clusterState = endpoint.endpoint.stateOf(behavior);
|
|
326
|
+
for (const attributeName in cluster.attributes) {
|
|
327
|
+
const attribute = cluster.attributes[attributeName];
|
|
328
|
+
if (attribute === void 0) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const attributeValue = clusterState[attributeName];
|
|
332
|
+
const { pathStr, value } = this.#convertAttributeToWebSocket(
|
|
333
|
+
{ endpointId, clusterId, attributeId: attribute.id },
|
|
334
|
+
attributeValue,
|
|
335
|
+
clusterData
|
|
336
|
+
);
|
|
337
|
+
attributesData[pathStr] = value;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const childEndpoint of endpoint.getChildEndpoints()) {
|
|
341
|
+
this.#collectAttributesFromEndpoint(childEndpoint, attributesData);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Convert attribute data to WebSocket tag-based format.
|
|
346
|
+
*/
|
|
347
|
+
#convertAttributeToWebSocket(path, value, clusterData) {
|
|
348
|
+
const { endpointId, clusterId, attributeId } = path;
|
|
349
|
+
if (!clusterData) {
|
|
350
|
+
const cluster = getClusterById(clusterId);
|
|
351
|
+
clusterData = ClusterMap[cluster.name.toLowerCase()];
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
pathStr: buildAttributePath(endpointId, clusterId, attributeId),
|
|
355
|
+
value: convertMatterToWebSocketTagBased(value, clusterData?.attributes[attributeId], clusterData?.model)
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Set the fabric label. Pass null or empty string to reset to "Home".
|
|
360
|
+
* Note: matter.js requires non-empty labels (1-32 chars), so null/empty resets to default.
|
|
361
|
+
*/
|
|
362
|
+
setFabricLabel(label) {
|
|
363
|
+
const effectiveLabel = label && label.trim() !== "" ? label : "Home";
|
|
364
|
+
return this.#controller.updateFabricLabel(effectiveLabel);
|
|
365
|
+
}
|
|
366
|
+
disconnectNode(nodeId) {
|
|
367
|
+
return this.#controller.disconnectNode(nodeId, true);
|
|
368
|
+
}
|
|
369
|
+
async handleReadAttribute(data) {
|
|
370
|
+
const { nodeId, endpointId, clusterId, attributeId, fabricFiltered = true } = data;
|
|
371
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
372
|
+
const { attributeData, attributeStatus } = await client.getMultipleAttributesAndStatus({
|
|
373
|
+
attributes: [{ endpointId, clusterId, attributeId }],
|
|
374
|
+
isFabricFiltered: fabricFiltered
|
|
375
|
+
});
|
|
376
|
+
return {
|
|
377
|
+
values: attributeData.map(
|
|
378
|
+
({ path: { endpointId: endpointId2, clusterId: clusterId2, attributeId: attributeId2 }, value, version: dataVersion }) => ({
|
|
379
|
+
attributeId: attributeId2,
|
|
380
|
+
clusterId: clusterId2,
|
|
381
|
+
dataVersion,
|
|
382
|
+
endpointId: endpointId2,
|
|
383
|
+
value
|
|
384
|
+
})
|
|
385
|
+
),
|
|
386
|
+
status: attributeStatus?.map(({ path: { endpointId: endpointId2, clusterId: clusterId2, attributeId: attributeId2 }, status, clusterStatus }) => ({
|
|
387
|
+
attributeId: attributeId2,
|
|
388
|
+
clusterId: clusterId2,
|
|
389
|
+
endpointId: endpointId2,
|
|
390
|
+
status,
|
|
391
|
+
clusterStatus
|
|
392
|
+
}))
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
async handleReadEvent(data) {
|
|
396
|
+
const { nodeId, endpointId, clusterId, eventId, eventMin } = data;
|
|
397
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
398
|
+
const { eventData, eventStatus } = await client.getMultipleEventsAndStatus({
|
|
399
|
+
events: [
|
|
400
|
+
{
|
|
401
|
+
endpointId,
|
|
402
|
+
clusterId,
|
|
403
|
+
eventId
|
|
404
|
+
}
|
|
405
|
+
],
|
|
406
|
+
eventFilters: eventMin ? [{ eventMin }] : void 0
|
|
407
|
+
});
|
|
408
|
+
return {
|
|
409
|
+
values: eventData.flatMap(
|
|
410
|
+
({ path: { endpointId: endpointId2, clusterId: clusterId2, eventId: eventId2 }, events }) => events.map(({ eventNumber, data: data2 }) => ({
|
|
411
|
+
eventId: eventId2,
|
|
412
|
+
clusterId: clusterId2,
|
|
413
|
+
endpointId: endpointId2,
|
|
414
|
+
eventNumber,
|
|
415
|
+
value: data2
|
|
416
|
+
}))
|
|
417
|
+
),
|
|
418
|
+
status: eventStatus?.map(({ path: { endpointId: endpointId2, clusterId: clusterId2, eventId: eventId2 }, status, clusterStatus }) => ({
|
|
419
|
+
clusterId: clusterId2,
|
|
420
|
+
endpointId: endpointId2,
|
|
421
|
+
eventId: eventId2,
|
|
422
|
+
status,
|
|
423
|
+
clusterStatus
|
|
424
|
+
}))
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
async handleSubscribeAttribute(data) {
|
|
428
|
+
const { nodeId, endpointId, clusterId, attributeId, minInterval, maxInterval, changeListener } = data;
|
|
429
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
430
|
+
const updated = Observable();
|
|
431
|
+
let ignoreData = true;
|
|
432
|
+
const { attributeReports = [] } = await client.subscribeMultipleAttributesAndEvents({
|
|
433
|
+
attributes: [
|
|
434
|
+
{
|
|
435
|
+
endpointId,
|
|
436
|
+
clusterId,
|
|
437
|
+
attributeId
|
|
438
|
+
}
|
|
439
|
+
],
|
|
440
|
+
minIntervalFloorSeconds: minInterval,
|
|
441
|
+
maxIntervalCeilingSeconds: maxInterval,
|
|
442
|
+
attributeListener: (data2) => {
|
|
443
|
+
if (ignoreData) return;
|
|
444
|
+
changeListener({
|
|
445
|
+
attributeId: data2.path.attributeId,
|
|
446
|
+
clusterId: data2.path.clusterId,
|
|
447
|
+
endpointId: data2.path.endpointId,
|
|
448
|
+
dataVersion: data2.version,
|
|
449
|
+
value: data2.value
|
|
450
|
+
});
|
|
451
|
+
},
|
|
452
|
+
updateReceived: () => {
|
|
453
|
+
updated.emit();
|
|
454
|
+
},
|
|
455
|
+
keepSubscriptions: false
|
|
456
|
+
});
|
|
457
|
+
ignoreData = false;
|
|
458
|
+
return {
|
|
459
|
+
values: attributeReports.map(
|
|
460
|
+
({ path: { endpointId: endpointId2, clusterId: clusterId2, attributeId: attributeId2 }, value, version: dataVersion }) => ({
|
|
461
|
+
attributeId: attributeId2,
|
|
462
|
+
clusterId: clusterId2,
|
|
463
|
+
endpointId: endpointId2,
|
|
464
|
+
dataVersion,
|
|
465
|
+
value
|
|
466
|
+
})
|
|
467
|
+
),
|
|
468
|
+
updated
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
async handleSubscribeEvent(data) {
|
|
472
|
+
const { nodeId, endpointId, clusterId, eventId, minInterval, maxInterval, changeListener } = data;
|
|
473
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
474
|
+
const updated = Observable();
|
|
475
|
+
let ignoreData = true;
|
|
476
|
+
const { eventReports = [] } = await client.subscribeMultipleAttributesAndEvents({
|
|
477
|
+
events: [
|
|
478
|
+
{
|
|
479
|
+
endpointId,
|
|
480
|
+
clusterId,
|
|
481
|
+
eventId
|
|
482
|
+
}
|
|
483
|
+
],
|
|
484
|
+
minIntervalFloorSeconds: minInterval,
|
|
485
|
+
maxIntervalCeilingSeconds: maxInterval,
|
|
486
|
+
eventListener: (data2) => {
|
|
487
|
+
if (ignoreData) return;
|
|
488
|
+
data2.events.forEach(
|
|
489
|
+
(event) => changeListener({
|
|
490
|
+
eventId: data2.path.eventId,
|
|
491
|
+
clusterId: data2.path.clusterId,
|
|
492
|
+
endpointId: data2.path.endpointId,
|
|
493
|
+
eventNumber: event.eventNumber,
|
|
494
|
+
value: event.data
|
|
495
|
+
})
|
|
496
|
+
);
|
|
497
|
+
},
|
|
498
|
+
updateReceived: () => {
|
|
499
|
+
updated.emit();
|
|
500
|
+
},
|
|
501
|
+
keepSubscriptions: false
|
|
502
|
+
});
|
|
503
|
+
ignoreData = false;
|
|
504
|
+
return {
|
|
505
|
+
values: eventReports.flatMap(
|
|
506
|
+
({ path: { endpointId: endpointId2, clusterId: clusterId2, eventId: eventId2 }, events }) => events.map(({ eventNumber, data: data2 }) => ({
|
|
507
|
+
eventId: eventId2,
|
|
508
|
+
clusterId: clusterId2,
|
|
509
|
+
endpointId: endpointId2,
|
|
510
|
+
eventNumber,
|
|
511
|
+
value: data2
|
|
512
|
+
}))
|
|
513
|
+
),
|
|
514
|
+
updated
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
async handleWriteAttribute(data) {
|
|
518
|
+
const { nodeId, endpointId, clusterId, attributeId, value } = data;
|
|
519
|
+
const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
|
|
520
|
+
logger.info("Writing attribute", attributeId, "with value", value);
|
|
521
|
+
try {
|
|
522
|
+
await client.attributes[attributeId].set(value);
|
|
523
|
+
return {
|
|
524
|
+
attributeId,
|
|
525
|
+
clusterId,
|
|
526
|
+
endpointId,
|
|
527
|
+
status: 0
|
|
528
|
+
};
|
|
529
|
+
} catch (error) {
|
|
530
|
+
StatusResponseError.accept(error);
|
|
531
|
+
return {
|
|
532
|
+
attributeId,
|
|
533
|
+
clusterId,
|
|
534
|
+
endpointId,
|
|
535
|
+
status: error.code,
|
|
536
|
+
clusterStatus: error.clusterCode
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async handleInvoke(data) {
|
|
541
|
+
const {
|
|
542
|
+
nodeId,
|
|
543
|
+
endpointId,
|
|
544
|
+
clusterId,
|
|
545
|
+
commandName,
|
|
546
|
+
timedInteractionTimeoutMs: timedRequestTimeoutMs,
|
|
547
|
+
interactionTimeoutMs
|
|
548
|
+
} = data;
|
|
549
|
+
let { data: commandData } = data;
|
|
550
|
+
const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
|
|
551
|
+
if (!client[commandName] || !client.isCommandSupportedByName(commandName)) {
|
|
552
|
+
throw new Error("Command not existing");
|
|
553
|
+
}
|
|
554
|
+
if (isObject(commandData)) {
|
|
555
|
+
if (Object.keys(commandData).length === 0) {
|
|
556
|
+
commandData = void 0;
|
|
557
|
+
} else {
|
|
558
|
+
const cluster = ClusterMap[client.name.toLowerCase()];
|
|
559
|
+
const model = cluster?.commands[commandName.toLowerCase()];
|
|
560
|
+
if (cluster && model) {
|
|
561
|
+
commandData = convertCommandDataToMatter(commandData, model, cluster.model);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return client[commandName](commandData, {
|
|
566
|
+
timedRequestTimeout: Millis(timedRequestTimeoutMs),
|
|
567
|
+
expectedProcessingTime: interactionTimeoutMs !== void 0 ? Millis(interactionTimeoutMs) : void 0
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
/** InvokeById minimalistic handler because only used for error testing */
|
|
571
|
+
async handleInvokeById(data) {
|
|
572
|
+
const { nodeId, endpointId, clusterId, commandId, data: commandData, timedInteractionTimeoutMs } = data;
|
|
573
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
574
|
+
await client.invoke({
|
|
575
|
+
endpointId,
|
|
576
|
+
clusterId,
|
|
577
|
+
command: Command(commandId, TlvAny, 0, TlvNoResponse, {
|
|
578
|
+
timed: timedInteractionTimeoutMs !== void 0
|
|
579
|
+
}),
|
|
580
|
+
request: commandData === void 0 ? TlvVoid.encodeTlv() : TlvObject({}).encodeTlv(commandData),
|
|
581
|
+
asTimedRequest: timedInteractionTimeoutMs !== void 0,
|
|
582
|
+
timedRequestTimeout: Millis(timedInteractionTimeoutMs),
|
|
583
|
+
skipValidation: true
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
async handleWriteAttributeById(data) {
|
|
587
|
+
const { nodeId, endpointId, clusterId, attributeId, value } = data;
|
|
588
|
+
const client = await this.#nodes.interactionClientFor(nodeId);
|
|
589
|
+
logger.info("Writing attribute", attributeId, "with value", value);
|
|
590
|
+
let tlvValue;
|
|
591
|
+
if (value === null) {
|
|
592
|
+
tlvValue = TlvNullable(TlvBoolean).encodeTlv(value);
|
|
593
|
+
} else if (value instanceof Uint8Array) {
|
|
594
|
+
tlvValue = TlvByteString.encodeTlv(value);
|
|
595
|
+
} else {
|
|
596
|
+
switch (typeof value) {
|
|
597
|
+
case "boolean":
|
|
598
|
+
tlvValue = TlvBoolean.encodeTlv(value);
|
|
599
|
+
break;
|
|
600
|
+
case "number":
|
|
601
|
+
tlvValue = TlvInt32.encodeTlv(value);
|
|
602
|
+
break;
|
|
603
|
+
case "bigint":
|
|
604
|
+
tlvValue = TlvUInt64.encodeTlv(value);
|
|
605
|
+
break;
|
|
606
|
+
case "string":
|
|
607
|
+
tlvValue = TlvString.encodeTlv(value);
|
|
608
|
+
break;
|
|
609
|
+
default:
|
|
610
|
+
throw new Error("Unsupported value type for Any encoding");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
await client.setAttribute({
|
|
614
|
+
attributeData: {
|
|
615
|
+
endpointId,
|
|
616
|
+
clusterId,
|
|
617
|
+
attribute: Attribute(attributeId, TlvAny),
|
|
618
|
+
value: tlvValue
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
#determineCommissionOptions(data) {
|
|
623
|
+
let passcode = void 0;
|
|
624
|
+
let shortDiscriminator = void 0;
|
|
625
|
+
let longDiscriminator = void 0;
|
|
626
|
+
let productId = void 0;
|
|
627
|
+
let vendorId = void 0;
|
|
628
|
+
let knownAddress = void 0;
|
|
629
|
+
if ("manualCode" in data && data.manualCode.length > 0) {
|
|
630
|
+
const pairingCodeCodec = ManualPairingCodeCodec.decode(data.manualCode);
|
|
631
|
+
shortDiscriminator = pairingCodeCodec.shortDiscriminator;
|
|
632
|
+
longDiscriminator = void 0;
|
|
633
|
+
passcode = pairingCodeCodec.passcode;
|
|
634
|
+
} else if ("qrCode" in data && data.qrCode.length > 0) {
|
|
635
|
+
const pairingCodeCodec = QrPairingCodeCodec.decode(data.qrCode);
|
|
636
|
+
longDiscriminator = pairingCodeCodec[0].discriminator;
|
|
637
|
+
shortDiscriminator = void 0;
|
|
638
|
+
passcode = pairingCodeCodec[0].passcode;
|
|
639
|
+
} else if ("passcode" in data) {
|
|
640
|
+
passcode = data.passcode;
|
|
641
|
+
if ("shortDiscriminator" in data) {
|
|
642
|
+
shortDiscriminator = data.shortDiscriminator;
|
|
643
|
+
} else if ("longDiscriminator" in data) {
|
|
644
|
+
longDiscriminator = data.longDiscriminator;
|
|
645
|
+
} else if ("vendorId" in data && "productId" in data) {
|
|
646
|
+
vendorId = VendorId(data.vendorId);
|
|
647
|
+
productId = data.productId;
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
throw new Error("No pairing code provided");
|
|
651
|
+
}
|
|
652
|
+
if (data.knownAddress !== void 0) {
|
|
653
|
+
const { ip, port } = data.knownAddress;
|
|
654
|
+
knownAddress = {
|
|
655
|
+
type: "udp",
|
|
656
|
+
ip,
|
|
657
|
+
port
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
if (passcode == void 0) {
|
|
661
|
+
throw new Error("No passcode provided");
|
|
662
|
+
}
|
|
663
|
+
const { onNetworkOnly } = data;
|
|
664
|
+
return {
|
|
665
|
+
commissioning: {
|
|
666
|
+
nodeId: data.nodeId,
|
|
667
|
+
regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
|
|
668
|
+
regulatoryCountryCode: "XX"
|
|
669
|
+
},
|
|
670
|
+
discovery: {
|
|
671
|
+
knownAddress,
|
|
672
|
+
identifierData: longDiscriminator !== void 0 ? { longDiscriminator } : shortDiscriminator !== void 0 ? { shortDiscriminator } : vendorId !== void 0 ? { vendorId, productId } : {},
|
|
673
|
+
discoveryCapabilities: {
|
|
674
|
+
ble: this.bleEnabled && !onNetworkOnly,
|
|
675
|
+
onIpNetwork: true
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
passcode
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
async commissionNode(data) {
|
|
682
|
+
const nodeId = await this.#controller.commissionNode(this.#determineCommissionOptions(data), {
|
|
683
|
+
connectNodeAfterCommissioning: true
|
|
684
|
+
});
|
|
685
|
+
await this.#registerNode(nodeId);
|
|
686
|
+
this.events.nodeAdded.emit(nodeId);
|
|
687
|
+
return { nodeId };
|
|
688
|
+
}
|
|
689
|
+
getCommissionerNodeId() {
|
|
690
|
+
return this.#controller.nodeId;
|
|
691
|
+
}
|
|
692
|
+
async getCommissionerFabricData() {
|
|
693
|
+
const { fabricId, globalId } = this.#controller.fabric;
|
|
694
|
+
return {
|
|
695
|
+
fabricId,
|
|
696
|
+
compressedFabricId: globalId
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
/** Discover commissionable devices */
|
|
700
|
+
async handleDiscovery({ findBy }) {
|
|
701
|
+
const result = await this.#controller.discoverCommissionableDevices(
|
|
702
|
+
findBy ?? {},
|
|
703
|
+
{ onIpNetwork: true },
|
|
704
|
+
void 0,
|
|
705
|
+
Seconds(3)
|
|
706
|
+
// Just check for 3 sec
|
|
707
|
+
);
|
|
708
|
+
logger.info("Discovered result", result);
|
|
709
|
+
const latestDiscovery = result[result.length - 1];
|
|
710
|
+
if (latestDiscovery === void 0) {
|
|
711
|
+
return [];
|
|
712
|
+
}
|
|
713
|
+
return [latestDiscovery].map(({ DT, DN, CM, D, RI, PH, PI, T, VP, deviceIdentifier, addresses, SII, SAI }) => {
|
|
714
|
+
const { tcpClient: supportsTcpClient, tcpServer: supportsTcpServer } = SupportedTransportsSchema.decode(
|
|
715
|
+
T ?? 0
|
|
716
|
+
);
|
|
717
|
+
const vendorId = VP === void 0 ? -1 : VP.includes("+") ? parseInt(VP.split("+")[0]) : parseInt(VP);
|
|
718
|
+
const productId = VP === void 0 ? -1 : VP.includes("+") ? parseInt(VP.split("+")[1]) : -1;
|
|
719
|
+
const port = addresses.length ? addresses[0].port : 0;
|
|
720
|
+
const numIPs = addresses.length;
|
|
721
|
+
return {
|
|
722
|
+
commissioningMode: CM,
|
|
723
|
+
deviceName: DN ?? "",
|
|
724
|
+
deviceType: DT ?? 0,
|
|
725
|
+
hostName: "000000000000",
|
|
726
|
+
// Right now we do not return real hostname, only used internally
|
|
727
|
+
instanceName: deviceIdentifier,
|
|
728
|
+
longDiscriminator: D,
|
|
729
|
+
numIPs,
|
|
730
|
+
pairingHint: PH ?? -1,
|
|
731
|
+
pairingInstruction: PI ?? "",
|
|
732
|
+
port,
|
|
733
|
+
productId,
|
|
734
|
+
rotatingId: RI ?? "",
|
|
735
|
+
rotatingIdLen: RI?.length ?? 0,
|
|
736
|
+
shortDiscriminator: D >> 8 & 15,
|
|
737
|
+
vendorId,
|
|
738
|
+
supportsTcpServer,
|
|
739
|
+
supportsTcpClient,
|
|
740
|
+
addresses: addresses.filter(({ type }) => type === "udp").map(({ ip }) => ip),
|
|
741
|
+
mrpSessionIdleInterval: SII,
|
|
742
|
+
mrpSessionActiveInterval: SAI
|
|
743
|
+
};
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
async getNodeIpAddresses(nodeId, preferCache = true) {
|
|
747
|
+
const node = this.#nodes.get(nodeId);
|
|
748
|
+
const addresses = /* @__PURE__ */ new Set();
|
|
749
|
+
const generalDiag = node.getRootClusterClient(GeneralDiagnosticsCluster);
|
|
750
|
+
if (generalDiag) {
|
|
751
|
+
try {
|
|
752
|
+
const networkInterfaces = await generalDiag.getNetworkInterfacesAttribute(preferCache ? true : true);
|
|
753
|
+
if (networkInterfaces) {
|
|
754
|
+
const interfaces = networkInterfaces.filter(({ isOperational }) => isOperational);
|
|
755
|
+
if (interfaces.length) {
|
|
756
|
+
logger.info(`Found ${interfaces.length} operational network interfaces`, interfaces);
|
|
757
|
+
interfaces.forEach(({ iPv4Addresses, iPv6Addresses }) => {
|
|
758
|
+
iPv4Addresses.forEach((ip) => addresses.add(ipv4BytesToString(Bytes.of(ip))));
|
|
759
|
+
iPv6Addresses.forEach((ip) => addresses.add(ipv6BytesToString(Bytes.of(ip))));
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
} catch (e) {
|
|
764
|
+
logger.info(`Failed to get network interfaces: ${e}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return Array.from(addresses.values());
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Ping a node on all its known IP addresses.
|
|
771
|
+
* @param nodeId The node ID to ping
|
|
772
|
+
* @param attempts Number of ping attempts per IP (default: 1)
|
|
773
|
+
* @returns A record of IP addresses to ping success status
|
|
774
|
+
*/
|
|
775
|
+
async pingNode(nodeId, attempts = 1) {
|
|
776
|
+
const node = this.#nodes.get(nodeId);
|
|
777
|
+
const result = {};
|
|
778
|
+
const ipAddresses = await this.getNodeIpAddresses(nodeId, false);
|
|
779
|
+
if (ipAddresses.length === 0) {
|
|
780
|
+
logger.info(`No IP addresses found for node ${nodeId}`);
|
|
781
|
+
return result;
|
|
782
|
+
}
|
|
783
|
+
logger.info(`Pinging node ${nodeId} on ${ipAddresses.length} addresses:`, ipAddresses);
|
|
784
|
+
const pingPromises = ipAddresses.map(async (ip) => {
|
|
785
|
+
const cleanIp = ip.includes("%") ? ip.split("%")[0] : ip;
|
|
786
|
+
logger.debug(`Pinging ${cleanIp}`);
|
|
787
|
+
const success = await pingIp(ip, 10, attempts);
|
|
788
|
+
result[ip] = success;
|
|
789
|
+
logger.debug(`Ping result for ${cleanIp}: ${success}`);
|
|
790
|
+
});
|
|
791
|
+
await Promise.all(pingPromises);
|
|
792
|
+
if (node.isConnected) {
|
|
793
|
+
const anySuccess = Object.values(result).some((v) => v);
|
|
794
|
+
if (!anySuccess && ipAddresses.length > 0) {
|
|
795
|
+
logger.info(`Node ${nodeId} is connected but no pings succeeded`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return result;
|
|
799
|
+
}
|
|
800
|
+
async decommissionNode(nodeId) {
|
|
801
|
+
const node = this.#nodes.has(nodeId) ? this.#nodes.get(nodeId) : void 0;
|
|
802
|
+
await this.#controller.removeNode(nodeId, !!node?.isConnected);
|
|
803
|
+
this.#nodes.delete(nodeId);
|
|
804
|
+
this.events.nodeDecommissioned.emit(nodeId);
|
|
805
|
+
}
|
|
806
|
+
async openCommissioningWindow(data) {
|
|
807
|
+
const { nodeId, timeout } = data;
|
|
808
|
+
const node = this.#nodes.get(nodeId);
|
|
809
|
+
const { manualPairingCode, qrPairingCode } = await node.openEnhancedCommissioningWindow(timeout);
|
|
810
|
+
return { manualCode: manualPairingCode, qrCode: qrPairingCode };
|
|
811
|
+
}
|
|
812
|
+
async getFabrics(nodeId) {
|
|
813
|
+
const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), OperationalCredentials.Cluster);
|
|
814
|
+
return (await client.getFabricsAttribute(true, false)).map(({ fabricId, fabricIndex, vendorId, label }) => ({
|
|
815
|
+
fabricId,
|
|
816
|
+
vendorId,
|
|
817
|
+
fabricIndex,
|
|
818
|
+
label
|
|
819
|
+
}));
|
|
820
|
+
}
|
|
821
|
+
removeFabric(nodeId, fabricIndex) {
|
|
822
|
+
const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), OperationalCredentials.Cluster);
|
|
823
|
+
return client.removeFabric({ fabricIndex });
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Set Access Control List entries on a node.
|
|
827
|
+
* Writes to the ACL attribute on the AccessControl cluster (endpoint 0).
|
|
828
|
+
*/
|
|
829
|
+
async setAclEntry(nodeId, entries) {
|
|
830
|
+
const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), AccessControl.Cluster);
|
|
831
|
+
const aclEntries = entries.map((entry) => ({
|
|
832
|
+
privilege: entry.privilege,
|
|
833
|
+
authMode: entry.auth_mode,
|
|
834
|
+
subjects: entry.subjects?.map((s) => NodeId(BigInt(s))) ?? null,
|
|
835
|
+
targets: entry.targets?.map((t) => ({
|
|
836
|
+
cluster: t.cluster !== null ? ClusterId(t.cluster) : null,
|
|
837
|
+
endpoint: t.endpoint !== null ? EndpointNumber(t.endpoint) : null,
|
|
838
|
+
deviceType: t.device_type !== null ? DeviceTypeId(t.device_type) : null
|
|
839
|
+
})) ?? null,
|
|
840
|
+
fabricIndex: FabricIndex.OMIT_FABRIC
|
|
841
|
+
}));
|
|
842
|
+
logger.info("Setting ACL entries", aclEntries);
|
|
843
|
+
try {
|
|
844
|
+
await client.setAclAttribute(aclEntries);
|
|
845
|
+
return [
|
|
846
|
+
{
|
|
847
|
+
path: {
|
|
848
|
+
endpoint_id: 0,
|
|
849
|
+
cluster_id: AccessControl.Cluster.id,
|
|
850
|
+
attribute_id: 0
|
|
851
|
+
// ACL attribute ID
|
|
852
|
+
},
|
|
853
|
+
status: 0
|
|
854
|
+
}
|
|
855
|
+
];
|
|
856
|
+
} catch (error) {
|
|
857
|
+
StatusResponseError.accept(error);
|
|
858
|
+
return [
|
|
859
|
+
{
|
|
860
|
+
path: {
|
|
861
|
+
endpoint_id: 0,
|
|
862
|
+
cluster_id: AccessControl.Cluster.id,
|
|
863
|
+
attribute_id: 0
|
|
864
|
+
},
|
|
865
|
+
status: error.code
|
|
866
|
+
}
|
|
867
|
+
];
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Set bindings on a specific endpoint of a node.
|
|
872
|
+
* Writes to the Binding attribute on the Binding cluster.
|
|
873
|
+
*/
|
|
874
|
+
async setNodeBinding(nodeId, endpointId, bindings) {
|
|
875
|
+
const client = this.#nodes.clusterClientFor(nodeId, endpointId, Binding.Cluster);
|
|
876
|
+
const bindingEntries = bindings.map((binding) => ({
|
|
877
|
+
node: binding.node !== null ? NodeId(binding.node) : void 0,
|
|
878
|
+
group: binding.group !== null ? GroupId(binding.group) : void 0,
|
|
879
|
+
endpoint: binding.endpoint !== null ? EndpointNumber(binding.endpoint) : void 0,
|
|
880
|
+
cluster: binding.cluster !== null ? ClusterId(binding.cluster) : void 0,
|
|
881
|
+
fabricIndex: FabricIndex.OMIT_FABRIC
|
|
882
|
+
}));
|
|
883
|
+
logger.info("Setting bindings on endpoint", endpointId, bindingEntries);
|
|
884
|
+
try {
|
|
885
|
+
await client.attributes.binding.set(bindingEntries);
|
|
886
|
+
return [
|
|
887
|
+
{
|
|
888
|
+
path: {
|
|
889
|
+
endpoint_id: endpointId,
|
|
890
|
+
cluster_id: Binding.Cluster.id,
|
|
891
|
+
attribute_id: 0
|
|
892
|
+
// Binding attribute ID
|
|
893
|
+
},
|
|
894
|
+
status: 0
|
|
895
|
+
}
|
|
896
|
+
];
|
|
897
|
+
} catch (error) {
|
|
898
|
+
StatusResponseError.accept(error);
|
|
899
|
+
return [
|
|
900
|
+
{
|
|
901
|
+
path: {
|
|
902
|
+
endpoint_id: endpointId,
|
|
903
|
+
cluster_id: Binding.Cluster.id,
|
|
904
|
+
attribute_id: 0
|
|
905
|
+
},
|
|
906
|
+
status: error.code
|
|
907
|
+
}
|
|
908
|
+
];
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Check if a software update is available for a node.
|
|
913
|
+
* First checks the cached updates from OTA events, then queries the DCL if not found.
|
|
914
|
+
*/
|
|
915
|
+
async checkNodeUpdate(nodeId) {
|
|
916
|
+
if (!this.#otaEnabled) {
|
|
917
|
+
throw new Error("OTA is disabled.");
|
|
918
|
+
}
|
|
919
|
+
const cachedUpdate = this.#availableUpdates.get(nodeId);
|
|
920
|
+
if (cachedUpdate) {
|
|
921
|
+
return this.#convertToMatterSoftwareVersion(cachedUpdate);
|
|
922
|
+
}
|
|
923
|
+
const node = this.#nodes.get(nodeId);
|
|
924
|
+
try {
|
|
925
|
+
const otaProvider = this.#controller.otaProvider;
|
|
926
|
+
if (!otaProvider) {
|
|
927
|
+
logger.info("OTA provider not available");
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
const updatesAvailable = await otaProvider.act(
|
|
931
|
+
(agent) => agent.get(SoftwareUpdateManager).queryUpdates({
|
|
932
|
+
peerToCheck: node.node,
|
|
933
|
+
includeStoredUpdates: true
|
|
934
|
+
})
|
|
935
|
+
);
|
|
936
|
+
const peerAddress = this.#controller.fabric.addressOf(nodeId);
|
|
937
|
+
const nodeUpdate = updatesAvailable.find(
|
|
938
|
+
({ peerAddress: updateAddress }) => PeerAddress.is(peerAddress, updateAddress)
|
|
939
|
+
);
|
|
940
|
+
if (nodeUpdate) {
|
|
941
|
+
const { info } = nodeUpdate;
|
|
942
|
+
this.#availableUpdates.set(nodeId, info);
|
|
943
|
+
return this.#convertToMatterSoftwareVersion(info);
|
|
944
|
+
}
|
|
945
|
+
return null;
|
|
946
|
+
} catch (error) {
|
|
947
|
+
logger.warn(`Failed to check for updates for node ${nodeId}:`, error);
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Trigger a software update for a node.
|
|
953
|
+
* @param nodeId The node to update
|
|
954
|
+
* @param softwareVersion The target software version to update to
|
|
955
|
+
*/
|
|
956
|
+
async updateNode(nodeId, softwareVersion) {
|
|
957
|
+
if (!this.#otaEnabled) {
|
|
958
|
+
throw new Error("OTA is disabled.");
|
|
959
|
+
}
|
|
960
|
+
if (!this.#nodes.has(nodeId)) {
|
|
961
|
+
throw new Error(`Node ${nodeId} not found`);
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
const otaProvider = this.#controller.otaProvider;
|
|
965
|
+
if (!otaProvider) {
|
|
966
|
+
throw new Error("OTA provider not available");
|
|
967
|
+
}
|
|
968
|
+
let updateInfo = this.#availableUpdates.get(nodeId);
|
|
969
|
+
if (!updateInfo) {
|
|
970
|
+
const result = await this.checkNodeUpdate(nodeId);
|
|
971
|
+
if (!result) {
|
|
972
|
+
throw new Error("No update available for this node");
|
|
973
|
+
}
|
|
974
|
+
updateInfo = this.#availableUpdates.get(nodeId);
|
|
975
|
+
if (!updateInfo) {
|
|
976
|
+
throw new Error("Failed to get update info");
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
logger.info(`Starting update for node ${nodeId} to version ${softwareVersion}`);
|
|
980
|
+
await otaProvider.act(
|
|
981
|
+
(agent) => agent.get(SoftwareUpdateManager).forceUpdate(
|
|
982
|
+
this.#controller.fabric.addressOf(nodeId),
|
|
983
|
+
updateInfo.vendorId,
|
|
984
|
+
updateInfo.productId,
|
|
985
|
+
softwareVersion
|
|
986
|
+
)
|
|
987
|
+
);
|
|
988
|
+
return this.#convertToMatterSoftwareVersion(updateInfo);
|
|
989
|
+
} catch (error) {
|
|
990
|
+
logger.error(`Failed to update node ${nodeId}:`, error);
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Convert SoftwareUpdateInfo to MatterSoftwareVersion format for WebSocket API.
|
|
996
|
+
*/
|
|
997
|
+
#convertToMatterSoftwareVersion(updateInfo) {
|
|
998
|
+
const { vendorId, productId, softwareVersion, softwareVersionString, releaseNotesUrl, source } = updateInfo;
|
|
999
|
+
return {
|
|
1000
|
+
vid: vendorId,
|
|
1001
|
+
pid: productId,
|
|
1002
|
+
software_version: softwareVersion,
|
|
1003
|
+
software_version_string: softwareVersionString,
|
|
1004
|
+
min_applicable_software_version: 0,
|
|
1005
|
+
// Not available from SoftwareUpdateInfo
|
|
1006
|
+
max_applicable_software_version: softwareVersion - 1,
|
|
1007
|
+
release_notes_url: releaseNotesUrl,
|
|
1008
|
+
update_source: source === "dcl-prod" ? UpdateSource.MAIN_NET_DCL : source === "dcl-test" ? UpdateSource.TEST_NET_DCL : UpdateSource.LOCAL
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
export {
|
|
1013
|
+
ControllerCommandHandler
|
|
1014
|
+
};
|
|
1015
|
+
//# sourceMappingURL=ControllerCommandHandler.js.map
|