@matter-server/ws-controller 0.2.2 → 0.2.4-alpha.0-20260116-d0b21f3

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.
Files changed (28) hide show
  1. package/dist/esm/controller/AttributeDataCache.d.ts.map +1 -1
  2. package/dist/esm/controller/AttributeDataCache.js +6 -0
  3. package/dist/esm/controller/AttributeDataCache.js.map +1 -1
  4. package/dist/esm/controller/ControllerCommandHandler.d.ts +2 -3
  5. package/dist/esm/controller/ControllerCommandHandler.d.ts.map +1 -1
  6. package/dist/esm/controller/ControllerCommandHandler.js +46 -103
  7. package/dist/esm/controller/ControllerCommandHandler.js.map +2 -2
  8. package/dist/esm/controller/CustomClusterPoller.d.ts +47 -0
  9. package/dist/esm/controller/CustomClusterPoller.d.ts.map +1 -0
  10. package/dist/esm/controller/CustomClusterPoller.js +171 -0
  11. package/dist/esm/controller/CustomClusterPoller.js.map +6 -0
  12. package/dist/esm/controller/TestNodeCommandHandler.d.ts +1 -6
  13. package/dist/esm/controller/TestNodeCommandHandler.d.ts.map +1 -1
  14. package/dist/esm/controller/TestNodeCommandHandler.js +0 -41
  15. package/dist/esm/controller/TestNodeCommandHandler.js.map +1 -1
  16. package/dist/esm/server/WebSocketControllerHandler.d.ts +1 -1
  17. package/dist/esm/server/WebSocketControllerHandler.d.ts.map +1 -1
  18. package/dist/esm/server/WebSocketControllerHandler.js +4 -2
  19. package/dist/esm/server/WebSocketControllerHandler.js.map +1 -1
  20. package/dist/esm/types/CommandHandler.d.ts +0 -4
  21. package/dist/esm/types/CommandHandler.d.ts.map +1 -1
  22. package/package.json +4 -4
  23. package/src/controller/AttributeDataCache.ts +6 -0
  24. package/src/controller/ControllerCommandHandler.ts +59 -128
  25. package/src/controller/CustomClusterPoller.ts +250 -0
  26. package/src/controller/TestNodeCommandHandler.ts +0 -56
  27. package/src/server/WebSocketControllerHandler.ts +4 -2
  28. package/src/types/CommandHandler.ts +0 -5
@@ -0,0 +1,250 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * Handles polling of custom cluster attributes that don't support subscriptions.
9
+ * This is primarily for Eve Energy devices that expose power measurements via
10
+ * a custom cluster without standard Matter subscription support.
11
+ */
12
+
13
+ import { CancelablePromise, Duration, Logger, Millis, NodeId, Time, Timer } from "@matter/main";
14
+ import { AttributesData } from "../types/CommandHandler.js";
15
+
16
+ const logger = Logger.get("CustomClusterPoller");
17
+
18
+ // Eve vendor ID (0x130A = 4874)
19
+ const VENDOR_ID_EVE = 4874;
20
+
21
+ // Eve custom cluster ID (0x130AFC01)
22
+ const EVE_CLUSTER_ID = 0x130afc01;
23
+
24
+ // Eve energy attribute IDs that should be polled
25
+ const EVE_ENERGY_ATTRIBUTE_IDS = {
26
+ watt: 0x130a000a,
27
+ wattAccumulated: 0x130a000b,
28
+ voltage: 0x130a0008,
29
+ current: 0x130a0009,
30
+ };
31
+
32
+ // Standard Matter ElectricalPowerMeasurement cluster ID
33
+ const ELECTRICAL_POWER_MEASUREMENT_CLUSTER_ID = 0x0090; // 144
34
+
35
+ // Polling interval in milliseconds (30 seconds, same as Python server)
36
+ const POLLING_INTERVAL_MS = 30_000;
37
+
38
+ // Maximum initial delay in milliseconds (random 0-30s to stagger startup)
39
+ const MAX_INITIAL_DELAY_MS = 30_000;
40
+
41
+ // Attribute path format: endpoint/cluster/attribute
42
+ type AttributePath = string;
43
+
44
+ export interface NodeAttributeReader {
45
+ handleReadAttributes(nodeId: NodeId, attributePaths: string[], fabricFiltered?: boolean): Promise<AttributesData>;
46
+ }
47
+
48
+ /**
49
+ * Check if a node needs custom attribute polling based on its attributes.
50
+ *
51
+ * A node needs polling if:
52
+ * 1. It has Eve vendor ID (4874) at endpoint 0/40/2 (BasicInformation.VendorID)
53
+ * 2. It has Eve custom cluster (0x130AFC01) with energy attributes
54
+ * 3. It does NOT have the standard ElectricalPowerMeasurement cluster (0x0090)
55
+ */
56
+ export function checkPolledAttributes(attributes: AttributesData): Set<AttributePath> {
57
+ const polledAttributes = new Set<AttributePath>();
58
+
59
+ // Check vendor ID - attribute path: 0/40/2 (endpoint 0, BasicInformation cluster, VendorID attribute)
60
+ const vendorId = attributes["0/40/2"];
61
+ if (vendorId !== VENDOR_ID_EVE) {
62
+ // Not an Eve device (or not the original Eve vendor - some bridges mimic Eve clusters)
63
+ return polledAttributes;
64
+ }
65
+
66
+ // Find endpoints that have the Eve cluster
67
+ const eveEndpoints = new Set<number>();
68
+ for (const attrPath of Object.keys(attributes)) {
69
+ const [endpointStr, clusterStr] = attrPath.split("/");
70
+ const clusterId = Number(clusterStr);
71
+ if (clusterId === EVE_CLUSTER_ID) {
72
+ eveEndpoints.add(Number(endpointStr));
73
+ }
74
+ }
75
+
76
+ if (eveEndpoints.size === 0) {
77
+ return polledAttributes;
78
+ }
79
+
80
+ // Check if ElectricalPowerMeasurement cluster exists (if so, no need to poll Eve cluster)
81
+ // The standard cluster would be on endpoint 2 typically
82
+ for (const attrPath of Object.keys(attributes)) {
83
+ const [, clusterStr] = attrPath.split("/");
84
+ const clusterId = Number(clusterStr);
85
+ if (clusterId === ELECTRICAL_POWER_MEASUREMENT_CLUSTER_ID) {
86
+ // Has standard cluster, no need to poll Eve energy attributes
87
+ logger.debug("Node has standard ElectricalPowerMeasurement cluster, skipping Eve energy polling");
88
+ return polledAttributes;
89
+ }
90
+ }
91
+
92
+ // Add Eve energy attributes to a poll for each Eve endpoint
93
+ for (const endpoint of eveEndpoints) {
94
+ for (const [, attributeId] of Object.entries(EVE_ENERGY_ATTRIBUTE_IDS)) {
95
+ const attrPath = `${endpoint}/${EVE_CLUSTER_ID}/${attributeId}`;
96
+ // Only add if the attribute exists in the node's attributes
97
+ if (attributes[attrPath] !== undefined) {
98
+ polledAttributes.add(attrPath);
99
+ }
100
+ }
101
+ }
102
+
103
+ if (polledAttributes.size > 0) {
104
+ logger.info(`Eve device detected, will poll ${polledAttributes.size} energy attributes`);
105
+ }
106
+
107
+ return polledAttributes;
108
+ }
109
+
110
+ /**
111
+ * Manages polling of custom cluster attributes for multiple nodes.
112
+ */
113
+ export class CustomClusterPoller {
114
+ #polledAttributes = new Map<NodeId, Set<AttributePath>>();
115
+ #pollerTimer: Timer;
116
+ #attributeReader: NodeAttributeReader;
117
+ #isPolling = false;
118
+ #currentDelayPromise?: CancelablePromise;
119
+ #closed = false;
120
+
121
+ constructor(attributeReader: NodeAttributeReader) {
122
+ this.#attributeReader = attributeReader;
123
+ const delay = Millis(Math.random() * MAX_INITIAL_DELAY_MS);
124
+ this.#pollerTimer = Time.getTimer("eve-poller", delay, () => this.#pollAllNodes());
125
+ }
126
+
127
+ /**
128
+ * Register a node for polling if it has custom attributes that need polling.
129
+ * Call this after a node is connected and its attributes are available.
130
+ */
131
+ registerNode(nodeId: NodeId, attributes: AttributesData): void {
132
+ const attributesToPoll = checkPolledAttributes(attributes);
133
+
134
+ if (attributesToPoll.size === 0) {
135
+ // Remove from polling if it was previously registered
136
+ this.unregisterNode(nodeId);
137
+ return;
138
+ }
139
+
140
+ this.#polledAttributes.set(nodeId, attributesToPoll);
141
+ logger.info(
142
+ `Registered node ${nodeId} for custom attribute polling: ${Array.from(attributesToPoll).join(", ")}`,
143
+ );
144
+
145
+ // Start the poller if not already running
146
+ this.#schedulePoller();
147
+ }
148
+
149
+ /**
150
+ * Unregister a node from polling (e.g., when decommissioned or disconnected).
151
+ */
152
+ unregisterNode(nodeId: NodeId): void {
153
+ if (this.#polledAttributes.delete(nodeId)) {
154
+ logger.info(`Unregistered node ${nodeId} from custom attribute polling`);
155
+ }
156
+ if (this.#polledAttributes.size === 0) {
157
+ this.#pollerTimer.stop();
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Stop all polling and cleanup.
163
+ */
164
+ stop(): void {
165
+ this.#closed = true;
166
+ this.#currentDelayPromise?.cancel(new Error("Close"));
167
+ this.#pollerTimer?.stop();
168
+ this.#polledAttributes.clear();
169
+ logger.info("Custom attribute poller stopped");
170
+ }
171
+
172
+ /**
173
+ * Schedule the next polling cycle.
174
+ * Uses a random initial delay (0-30s) on first run to stagger startup,
175
+ * then polls every 30s thereafter.
176
+ */
177
+ #schedulePoller(): void {
178
+ // No schedule if no nodes to poll
179
+ if (this.#polledAttributes.size === 0 || this.#closed) {
180
+ return;
181
+ }
182
+
183
+ // Don't schedule if already scheduled
184
+ if (this.#pollerTimer?.isRunning || this.#isPolling) {
185
+ return;
186
+ }
187
+
188
+ // Set the new interval
189
+ this.#pollerTimer.start();
190
+ logger.info(`Scheduling custom attribute poll in ${Duration.format(this.#pollerTimer.interval)}`);
191
+ }
192
+
193
+ /**
194
+ * Poll all registered nodes for their custom attributes.
195
+ */
196
+ async #pollAllNodes(): Promise<void> {
197
+ if (this.#isPolling) {
198
+ // Already polling, schedule next cycle
199
+ return;
200
+ }
201
+
202
+ const targetInterval = Millis(POLLING_INTERVAL_MS);
203
+ if (this.#pollerTimer.interval !== targetInterval) {
204
+ this.#pollerTimer.interval = targetInterval;
205
+ }
206
+
207
+ this.#isPolling = true;
208
+
209
+ try {
210
+ const entries = Array.from(this.#polledAttributes.entries());
211
+ for (let i = 0; i < entries.length; i++) {
212
+ const [nodeId, attributePaths] = entries[i];
213
+ if (!this.#polledAttributes.has(nodeId)) {
214
+ // Node was removed, so skip it
215
+ continue;
216
+ }
217
+ await this.#pollNode(nodeId, attributePaths);
218
+ // Small delay between nodes to avoid overwhelming the network
219
+ // Only add this delay if there are more nodes remaining to be polled
220
+ if (i < entries.length - 1) {
221
+ this.#currentDelayPromise = Time.sleep("sleep", Millis(1_000)).finally(() => {
222
+ this.#currentDelayPromise = undefined;
223
+ });
224
+ await this.#currentDelayPromise;
225
+ }
226
+ }
227
+ } finally {
228
+ this.#isPolling = false;
229
+ // Schedule next polling cycle
230
+ this.#schedulePoller();
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Poll a single node for its custom attributes.
236
+ * The read will automatically trigger change events through the normal attribute flow.
237
+ */
238
+ async #pollNode(nodeId: NodeId, attributePaths: Set<AttributePath>): Promise<void> {
239
+ const paths = Array.from(attributePaths);
240
+ logger.debug(`Polling ${paths.length} custom attributes for node ${nodeId}`);
241
+
242
+ try {
243
+ // Read with fabricFiltered=true as per Eve's requirements
244
+ // This automatically updates the attribute cache and triggers change events
245
+ await this.#attributeReader.handleReadAttributes(nodeId, paths, true);
246
+ } catch (error) {
247
+ logger.warn(`Failed to poll custom attributes for node ${nodeId}: `, error);
248
+ }
249
+ }
250
+ }
@@ -12,8 +12,6 @@ import {
12
12
  InvokeRequest,
13
13
  MatterNodeData,
14
14
  NodeCommandHandler,
15
- ReadAttributeRequest,
16
- ReadAttributeResponse,
17
15
  WriteAttributeRequest,
18
16
  } from "../types/CommandHandler.js";
19
17
  import { MatterNode, TEST_NODE_START } from "../types/WebSocketMessageTypes.js";
@@ -183,60 +181,6 @@ export class TestNodeCommandHandler implements NodeCommandHandler {
183
181
  return importedNodeIds;
184
182
  }
185
183
 
186
- /**
187
- * Read attributes from a test node.
188
- * Returns values from the stored attributes map.
189
- */
190
- async handleReadAttribute(data: ReadAttributeRequest): Promise<ReadAttributeResponse> {
191
- const { nodeId, endpointId, clusterId, attributeId } = data;
192
- const testNode = this.#testNodes.get(BigInt(nodeId));
193
-
194
- if (testNode === undefined) {
195
- throw new Error(`Test node ${nodeId} not found`);
196
- }
197
-
198
- const values: ReadAttributeResponse["values"] = [];
199
-
200
- // Build the path pattern for matching
201
- const hasWildcards = endpointId === undefined || clusterId === undefined || attributeId === undefined;
202
-
203
- if (hasWildcards) {
204
- // Match against all stored attributes
205
- for (const [attrPath, value] of Object.entries(testNode.attributes)) {
206
- const parts = attrPath.split("/").map(Number);
207
- if (
208
- (endpointId === undefined || parts[0] === endpointId) &&
209
- (clusterId === undefined || parts[1] === clusterId) &&
210
- (attributeId === undefined || parts[2] === attributeId)
211
- ) {
212
- values.push({
213
- endpointId: parts[0],
214
- clusterId: parts[1],
215
- attributeId: parts[2],
216
- dataVersion: 0,
217
- value,
218
- });
219
- }
220
- }
221
- } else {
222
- // Direct path lookup
223
- const path = `${endpointId}/${clusterId}/${attributeId}`;
224
- const value = testNode.attributes[path];
225
- if (value !== undefined) {
226
- values.push({
227
- endpointId,
228
- clusterId,
229
- attributeId,
230
- dataVersion: 0,
231
- value,
232
- });
233
- }
234
- }
235
-
236
- logger.debug(`read_attribute for test node ${nodeId}: ${values.length} values`);
237
- return { values };
238
- }
239
-
240
184
  /**
241
185
  * Write an attribute to a test node.
242
186
  * Logs the write and returns success (no actual write occurs).
@@ -51,6 +51,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
51
51
  #commandHandler: ControllerCommandHandler;
52
52
  #testNodeHandler: TestNodeCommandHandler;
53
53
  #config: ConfigStorage;
54
+ #serverVersion: string;
54
55
  #wss?: WebSocketServer;
55
56
  #closed = false;
56
57
  /** Circular buffer for recent node events (max 25) */
@@ -58,11 +59,12 @@ export class WebSocketControllerHandler implements WebServerHandler {
58
59
  /** Track when each node was last interviewed (connected) - keyed by nodeId */
59
60
  #lastInterviewDates = new Map<NodeId, Date>();
60
61
 
61
- constructor(controller: MatterController, config: ConfigStorage) {
62
+ constructor(controller: MatterController, config: ConfigStorage, serverVersion: string) {
62
63
  this.#controller = controller;
63
64
  this.#commandHandler = controller.commandHandler;
64
65
  this.#testNodeHandler = new TestNodeCommandHandler();
65
66
  this.#config = config;
67
+ this.#serverVersion = serverVersion;
66
68
  }
67
69
 
68
70
  /**
@@ -433,7 +435,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
433
435
  compressed_fabric_id,
434
436
  schema_version: SCHEMA_VERSION,
435
437
  min_supported_schema_version: SCHEMA_VERSION,
436
- sdk_version: `matter.js/${MATTER_VERSION}`,
438
+ sdk_version: `matter-server/${this.#serverVersion} (matter.js/${MATTER_VERSION})`,
437
439
  wifi_credentials_set: !!(this.#config.wifiSsid && this.#config.wifiCredentials),
438
440
  thread_credentials_set: !!this.#config.threadDataset,
439
441
  bluetooth_enabled: this.#commandHandler.bleEnabled,
@@ -238,11 +238,6 @@ export interface NodeCommandHandler {
238
238
  */
239
239
  handleReadAttributes(nodeId: NodeId, attributePaths: string[], fabricFiltered?: boolean): Promise<AttributesData>;
240
240
 
241
- /**
242
- * Read attributes from a node.
243
- */
244
- handleReadAttribute(data: ReadAttributeRequest): Promise<ReadAttributeResponse>;
245
-
246
241
  /**
247
242
  * Write an attribute to a node.
248
243
  */