@enyo-energy/sunspec-sdk 0.0.50 → 0.0.52

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.
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SunspecModbusClient = void 0;
4
+ exports.getOrCreateSunspecClient = getOrCreateSunspecClient;
5
+ exports.releaseSunspecClient = releaseSunspecClient;
4
6
  /**
5
7
  * Sunspec Modbus Client Implementation
6
8
  *
@@ -23,165 +25,351 @@ const sunspec_interfaces_js_1 = require("./sunspec-interfaces.cjs");
23
25
  const EnergyAppModbusConnectionHealth_js_1 = require("@enyo-energy/energy-app-sdk/dist/implementations/modbus/EnergyAppModbusConnectionHealth.js");
24
26
  const EnergyAppModbusFaultTolerantReader_js_1 = require("@enyo-energy/energy-app-sdk/dist/implementations/modbus/EnergyAppModbusFaultTolerantReader.js");
25
27
  const EnergyAppModbusDataTypeConverter_js_1 = require("@enyo-energy/energy-app-sdk/dist/implementations/modbus/EnergyAppModbusDataTypeConverter.js");
28
+ /**
29
+ * Module-level registry: one SunspecModbusClient per network device (host:port).
30
+ * Refcounted so the underlying connection only tears down when the last consumer releases.
31
+ */
32
+ const CLIENT_REGISTRY = new Map();
33
+ const registryKey = (host, port) => `${host}:${port}`;
34
+ /**
35
+ * Get (or create) the singleton SunspecModbusClient for this network device.
36
+ * Increments the refcount; pair with `releaseSunspecClient` on teardown.
37
+ */
38
+ function getOrCreateSunspecClient(energyApp, host, port = 502) {
39
+ const key = registryKey(host, port);
40
+ let entry = CLIENT_REGISTRY.get(key);
41
+ if (!entry) {
42
+ entry = { client: new SunspecModbusClient(energyApp), refcount: 0 };
43
+ CLIENT_REGISTRY.set(key, entry);
44
+ }
45
+ entry.refcount++;
46
+ return entry.client;
47
+ }
48
+ /**
49
+ * Release a refcount on the network-device client. When the last consumer releases,
50
+ * the client is fully disconnected (all units closed) and removed from the registry.
51
+ *
52
+ * Releasing more times than acquired is a programming error and is logged; the count
53
+ * is clamped at zero so repeat releases are no-ops.
54
+ */
55
+ async function releaseSunspecClient(host, port = 502) {
56
+ const key = registryKey(host, port);
57
+ const entry = CLIENT_REGISTRY.get(key);
58
+ if (!entry)
59
+ return;
60
+ if (entry.refcount <= 0) {
61
+ console.warn(`releaseSunspecClient(${host}:${port}): refcount already 0 — extra release ignored`);
62
+ return;
63
+ }
64
+ entry.refcount--;
65
+ if (entry.refcount === 0) {
66
+ // Disconnect first, THEN remove from registry, so a concurrent getOrCreate
67
+ // doesn't bind a fresh client to the same host:port while sockets are still
68
+ // closing on the old one.
69
+ try {
70
+ await entry.client.disconnect();
71
+ }
72
+ finally {
73
+ // Guard against the very-unlikely case where someone re-acquired during await.
74
+ if (CLIENT_REGISTRY.get(key) === entry && entry.refcount === 0) {
75
+ CLIENT_REGISTRY.delete(key);
76
+ }
77
+ }
78
+ }
79
+ }
26
80
  class SunspecModbusClient {
27
81
  energyApp;
28
- modbusClient = null;
29
- discoveredModels = new Map();
30
- connected = false;
82
+ // Per-unit Modbus instance/reader/discovered-models maps. One client = one network device,
83
+ // many unit IDs share that client (each unit ID is one EnergyAppModbusInstance underneath).
84
+ modbusInstances = new Map();
85
+ faultTolerantReaders = new Map();
86
+ discoveredModelsByUnit = new Map();
31
87
  connectionHealth;
32
- faultTolerantReader = null;
33
88
  modbusDataTypeConverter;
34
89
  connectionParams = null;
90
+ // Per-unit known parameters so reconnect() can replay every unit ID we've seen.
91
+ knownUnits = new Set();
35
92
  autoReconnectEnabled = true;
36
93
  openCount = 0;
37
94
  closeCount = 0;
38
- currentlyOpen = 0;
95
+ // Serializes connection-state transitions so concurrent callers cannot open duplicate
96
+ // sockets for the same unit ID while one is still alive.
97
+ operationChain = Promise.resolve();
39
98
  constructor(energyApp) {
40
99
  this.energyApp = energyApp;
41
100
  this.connectionHealth = new EnergyAppModbusConnectionHealth_js_1.EnergyAppModbusConnectionHealth();
42
101
  this.modbusDataTypeConverter = new EnergyAppModbusDataTypeConverter_js_1.EnergyAppModbusDataTypeConverter();
43
102
  }
44
103
  /**
45
- * Connect to Modbus device
104
+ * Connect to a Modbus device unit. Multiple unit IDs on the same network device share this
105
+ * client; calling connect() with different unit IDs adds each as a separately-managed unit.
106
+ * The first call locks in the host/port for this client; later calls for a different host
107
+ * are rejected.
46
108
  * @param host Primary host (hostname) to connect to
47
109
  * @param port Modbus port (default 502)
48
110
  * @param unitId Modbus unit ID (default 1)
49
111
  * @param secondaryHost Optional secondary host (ipAddress) for fallback during reconnection
50
112
  */
51
113
  async connect(host, port = 502, unitId = 1, secondaryHost) {
52
- if (this.connected) {
53
- await this.disconnect();
54
- }
55
- // Store connection parameters for potential reconnection
56
- this.connectionParams = { primaryHost: host, secondaryHost, port, unitId };
57
- this.modbusClient = await this.energyApp.useModbus().connect({
58
- host,
59
- port,
60
- unitId,
61
- timeout: 5000
114
+ return this.withConnectionLock(async () => {
115
+ if (this.connectionParams) {
116
+ if (this.connectionParams.primaryHost !== host || this.connectionParams.port !== port) {
117
+ throw new Error(`SunspecModbusClient is bound to ${this.connectionParams.primaryHost}:${this.connectionParams.port}; ` +
118
+ `cannot reuse it for ${host}:${port}. Use getOrCreateSunspecClient() per (host, port).`);
119
+ }
120
+ if (secondaryHost && !this.connectionParams.secondaryHost) {
121
+ this.connectionParams.secondaryHost = secondaryHost;
122
+ }
123
+ }
124
+ else {
125
+ this.connectionParams = { primaryHost: host, secondaryHost, port };
126
+ }
127
+ this.knownUnits.add(unitId);
128
+ if (this.modbusInstances.has(unitId)) {
129
+ console.debug(`connect(): unit ${unitId} on ${host}:${port} already open, skipping`);
130
+ return;
131
+ }
132
+ await this._openUnit(host, port, unitId);
133
+ console.log(`Connected to Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, openUnits=${this.modbusInstances.size})`);
62
134
  });
63
- // Create fault-tolerant reader with connection health monitoring
64
- if (this.modbusClient) {
65
- this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader_js_1.EnergyAppModbusFaultTolerantReader(this.modbusClient, this.connectionHealth);
66
- }
67
- this.connected = true;
68
- this.connectionHealth.recordSuccess();
69
- this.recordOpen();
70
- console.log(`Connected to Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
71
135
  }
72
136
  /**
73
- * Disconnect from Modbus device.
137
+ * Disconnect from all units of this network device.
74
138
  *
75
- * Note: connection parameters are preserved so reconnect() can be called afterwards.
76
- * They will be overwritten by the next connect() call anyway.
139
+ * Note: connection parameters and the set of known units are preserved so reconnect() can
140
+ * be called afterwards. They will be overwritten by the next connect() call anyway.
77
141
  */
78
142
  async disconnect() {
79
- if (this.modbusClient && this.connected) {
80
- await this.modbusClient.disconnect();
81
- this.modbusClient = null;
82
- this.faultTolerantReader = null;
83
- this.connected = false;
84
- this.discoveredModels.clear();
85
- this.recordClose();
143
+ return this.withConnectionLock(async () => {
144
+ if (this.modbusInstances.size === 0) {
145
+ return;
146
+ }
86
147
  const host = this.connectionParams?.primaryHost ?? 'unknown';
87
148
  const port = this.connectionParams?.port ?? 0;
88
- const unitId = this.connectionParams?.unitId ?? 0;
89
- console.log(`Disconnected from Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
90
- }
149
+ const units = [...this.modbusInstances.keys()];
150
+ for (const unitId of units) {
151
+ await this._closeUnit(unitId);
152
+ }
153
+ console.log(`Disconnected from Sunspec device at ${host}:${port} units [${units.join(',')}] (opens=${this.openCount}, closes=${this.closeCount})`);
154
+ });
155
+ }
156
+ /**
157
+ * Disconnect a single unit on this network device. Other units on the same device stay open.
158
+ */
159
+ async disconnectUnit(unitId) {
160
+ return this.withConnectionLock(async () => {
161
+ if (!this.modbusInstances.has(unitId))
162
+ return;
163
+ const host = this.connectionParams?.primaryHost ?? 'unknown';
164
+ const port = this.connectionParams?.port ?? 0;
165
+ await this._closeUnit(unitId);
166
+ console.log(`Disconnected unit ${unitId} from Sunspec device at ${host}:${port} (opens=${this.openCount}, closes=${this.closeCount})`);
167
+ });
91
168
  }
92
169
  /**
93
- * Reconnect using stored connection parameters
94
- * First tries primaryHost (hostname), then falls back to secondaryHost (ipAddress) if available
95
- * Returns true if reconnection was successful, false otherwise
170
+ * Reconnect every previously-known unit on this network device using stored connection
171
+ * parameters. First tries primaryHost (hostname), then falls back to secondaryHost
172
+ * (ipAddress) if available. Returns true only if every known unit reconnected successfully.
96
173
  */
97
174
  async reconnect() {
98
- if (!this.connectionParams) {
99
- console.error('Cannot reconnect: no connection parameters stored. Call connect() first.');
175
+ return this.withConnectionLock(async () => {
176
+ if (!this.connectionParams) {
177
+ console.error('Cannot reconnect: no connection parameters stored. Call connect() first.');
178
+ return false;
179
+ }
180
+ if (this.knownUnits.size === 0) {
181
+ console.error('Cannot reconnect: no known unit IDs. Call connect(unitId) at least once first.');
182
+ return false;
183
+ }
184
+ const { primaryHost, secondaryHost, port } = this.connectionParams;
185
+ const units = [...this.knownUnits];
186
+ console.log(`Attempting to reconnect to primary host ${primaryHost}:${port} units [${units.join(',')}]...`);
187
+ const primarySuccess = await this.attemptConnection(primaryHost, port, units);
188
+ if (primarySuccess) {
189
+ console.log(`Successfully reconnected to primary host ${primaryHost}:${port} units [${units.join(',')}]`);
190
+ return true;
191
+ }
192
+ if (secondaryHost && secondaryHost !== primaryHost) {
193
+ console.log(`Primary host failed, attempting secondary host ${secondaryHost}:${port} units [${units.join(',')}]...`);
194
+ const secondarySuccess = await this.attemptConnection(secondaryHost, port, units);
195
+ if (secondarySuccess) {
196
+ console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} units [${units.join(',')}]`);
197
+ return true;
198
+ }
199
+ }
200
+ console.error(`Reconnection failed to all available hosts`);
201
+ return false;
202
+ });
203
+ }
204
+ /**
205
+ * Reconnect a single previously-known unit on this network device. Other units on the
206
+ * same client stay open. Useful when one device's polling loop detects a dropped
207
+ * connection and only needs to recover its own unit, not thrash siblings.
208
+ *
209
+ * Returns true if the unit reconnected successfully (on primary or secondary host).
210
+ */
211
+ async reconnectUnit(unitId) {
212
+ return this.withConnectionLock(async () => {
213
+ if (!this.connectionParams) {
214
+ console.error(`Cannot reconnectUnit(${unitId}): no connection parameters stored. Call connect() first.`);
215
+ return false;
216
+ }
217
+ const { primaryHost, secondaryHost, port } = this.connectionParams;
218
+ console.log(`Attempting to reconnect unit ${unitId} on primary host ${primaryHost}:${port}...`);
219
+ const primarySuccess = await this.attemptConnection(primaryHost, port, [unitId]);
220
+ if (primarySuccess) {
221
+ console.log(`Successfully reconnected unit ${unitId} on primary host ${primaryHost}:${port}`);
222
+ return true;
223
+ }
224
+ if (secondaryHost && secondaryHost !== primaryHost) {
225
+ console.log(`Primary host failed for unit ${unitId}, attempting secondary host ${secondaryHost}:${port}...`);
226
+ const secondarySuccess = await this.attemptConnection(secondaryHost, port, [unitId]);
227
+ if (secondarySuccess) {
228
+ console.log(`Successfully reconnected unit ${unitId} on secondary host ${secondaryHost}:${port}`);
229
+ return true;
230
+ }
231
+ }
232
+ console.error(`Reconnection failed for unit ${unitId} on all available hosts`);
100
233
  return false;
234
+ });
235
+ }
236
+ /**
237
+ * Attempt to (re)open every requested unit on a specific host. Caller must hold the
238
+ * connection lock. Closes any pre-existing per-unit instances first. Returns true only
239
+ * if every unit succeeded.
240
+ */
241
+ async attemptConnection(host, port, units) {
242
+ for (const unitId of units) {
243
+ if (this.modbusInstances.has(unitId)) {
244
+ await this._closeUnit(unitId);
245
+ }
101
246
  }
102
- const { primaryHost, secondaryHost, port, unitId } = this.connectionParams;
103
- // Try primary host first
104
- console.log(`Attempting to reconnect to primary host ${primaryHost}:${port} unit ${unitId}...`);
105
- const primarySuccess = await this.attemptConnection(primaryHost, port, unitId);
106
- if (primarySuccess) {
107
- console.log(`Successfully reconnected to primary host ${primaryHost}:${port} unit ${unitId}`);
247
+ try {
248
+ for (const unitId of units) {
249
+ await this._openUnit(host, port, unitId);
250
+ }
251
+ console.log(`Connection attempt to ${host}:${port} units [${units.join(',')}] succeeded (opens=${this.openCount}, closes=${this.closeCount}, openUnits=${this.modbusInstances.size})`);
108
252
  return true;
109
253
  }
110
- // If primary failed and secondary is available, try secondary
111
- if (secondaryHost && secondaryHost !== primaryHost) {
112
- console.log(`Primary host failed, attempting secondary host ${secondaryHost}:${port} unit ${unitId}...`);
113
- const secondarySuccess = await this.attemptConnection(secondaryHost, port, unitId);
114
- if (secondarySuccess) {
115
- console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} unit ${unitId}`);
116
- return true;
117
- }
254
+ catch (error) {
255
+ console.error(`Connection attempt to ${host}:${port} failed: ${error}`);
256
+ return false;
118
257
  }
119
- console.error(`Reconnection failed to all available hosts`);
120
- this.connected = false;
121
- return false;
122
258
  }
123
259
  /**
124
- * Attempt to establish a connection to a specific host
125
- * Returns true if successful, false otherwise
260
+ * Open a new Modbus instance for one unit ID and wire up its fault-tolerant reader.
261
+ * On any failure, the just-opened instance (if any) is closed so we never leak. Caller must
262
+ * hold the connection lock and ensure the unit is not already open.
126
263
  */
127
- async attemptConnection(host, port, unitId) {
264
+ async _openUnit(host, port, unitId) {
265
+ let candidate = null;
128
266
  try {
129
- // Disconnect existing connection if any
130
- if (this.modbusClient) {
131
- const wasConnected = this.connected;
132
- try {
133
- await this.modbusClient.disconnect();
134
- }
135
- catch (e) {
136
- // Ignore disconnect errors during reconnection
137
- }
138
- this.modbusClient = null;
139
- this.faultTolerantReader = null;
140
- if (wasConnected) {
141
- this.connected = false;
142
- this.recordClose();
143
- }
144
- }
145
- // Attempt connection
146
- this.modbusClient = await this.energyApp.useModbus().connect({
267
+ candidate = await this.energyApp.useModbus().connect({
147
268
  host,
148
269
  port,
149
270
  unitId,
150
271
  timeout: 5000
151
272
  });
152
- // Create fault-tolerant reader with connection health monitoring
153
- if (this.modbusClient) {
154
- this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader_js_1.EnergyAppModbusFaultTolerantReader(this.modbusClient, this.connectionHealth);
273
+ if (!candidate) {
274
+ throw new Error(`useModbus().connect returned null for ${host}:${port} unit ${unitId}`);
155
275
  }
156
- this.connected = true;
276
+ const reader = new EnergyAppModbusFaultTolerantReader_js_1.EnergyAppModbusFaultTolerantReader(candidate, this.connectionHealth);
277
+ this.modbusInstances.set(unitId, candidate);
278
+ this.faultTolerantReaders.set(unitId, reader);
157
279
  this.connectionHealth.recordSuccess();
158
280
  this.recordOpen();
159
- console.log(`Connection attempt to ${host}:${port} unit ${unitId} succeeded (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
160
- return true;
161
281
  }
162
- catch (error) {
163
- console.error(`Connection attempt to ${host}:${port} failed: ${error}`);
164
- return false;
282
+ catch (err) {
283
+ if (candidate) {
284
+ try {
285
+ await candidate.disconnect();
286
+ }
287
+ catch { /* ignore */ }
288
+ }
289
+ this.modbusInstances.delete(unitId);
290
+ this.faultTolerantReaders.delete(unitId);
291
+ throw err;
292
+ }
293
+ }
294
+ /**
295
+ * Close the Modbus instance for a single unit. Idempotent. Caller must hold the
296
+ * connection lock.
297
+ */
298
+ async _closeUnit(unitId) {
299
+ const instance = this.modbusInstances.get(unitId);
300
+ const wasOpen = instance !== undefined;
301
+ this.modbusInstances.delete(unitId);
302
+ this.faultTolerantReaders.delete(unitId);
303
+ this.discoveredModelsByUnit.delete(unitId);
304
+ if (instance) {
305
+ try {
306
+ await instance.disconnect();
307
+ }
308
+ catch { /* ignore */ }
165
309
  }
310
+ if (wasOpen)
311
+ this.recordClose();
312
+ }
313
+ /**
314
+ * Run `fn` with exclusive access to connection-state transitions. Subsequent calls queue
315
+ * behind any in-flight one. A rejected `fn` does not poison the chain for later callers.
316
+ */
317
+ withConnectionLock(fn) {
318
+ const run = this.operationChain.then(fn, fn);
319
+ this.operationChain = run.catch(() => undefined);
320
+ return run;
166
321
  }
167
322
  recordOpen() {
168
323
  this.openCount++;
169
- this.currentlyOpen++;
324
+ const expected = this.modbusInstances.size;
325
+ const delta = this.openCount - this.closeCount;
326
+ if (delta > expected) {
327
+ console.error(`SunspecModbusClient: open/close imbalance — ` +
328
+ `opens=${this.openCount}, closes=${this.closeCount}, instances=${expected}. ` +
329
+ `This indicates a code path bypassing the connection lock — please investigate.`);
330
+ }
170
331
  }
171
332
  recordClose() {
172
333
  this.closeCount++;
173
- if (this.currentlyOpen > 0) {
174
- this.currentlyOpen--;
175
- }
176
- else {
177
- console.warn(`SunspecModbusClient: closeCount incremented while currentlyOpen was 0 — possible double-close`);
334
+ if (this.closeCount > this.openCount) {
335
+ console.warn(`SunspecModbusClient: closeCount=${this.closeCount} exceeded openCount=${this.openCount} — possible double-close`);
178
336
  }
179
337
  }
180
338
  /**
181
- * Get cumulative open/close counts for this client. Useful for spotting connection leaks.
339
+ * Get cumulative open/close counts for this client (across all unit IDs). Useful for
340
+ * spotting connection leaks.
182
341
  */
183
342
  getConnectionStats() {
184
- return { opens: this.openCount, closes: this.closeCount, currentlyOpen: this.currentlyOpen };
343
+ return { opens: this.openCount, closes: this.closeCount, openUnits: this.modbusInstances.size };
344
+ }
345
+ /**
346
+ * Get the EnergyAppModbusInstance for a unit, throwing if it isn't open.
347
+ */
348
+ getInstance(unitId) {
349
+ const inst = this.modbusInstances.get(unitId);
350
+ if (!inst)
351
+ throw new Error(`Modbus instance for unit ${unitId} not initialized — call connect(host, port, ${unitId}) first`);
352
+ return inst;
353
+ }
354
+ /**
355
+ * Get the fault-tolerant reader for a unit, throwing if it isn't open.
356
+ */
357
+ getReader(unitId) {
358
+ const reader = this.faultTolerantReaders.get(unitId);
359
+ if (!reader)
360
+ throw new Error(`Fault-tolerant reader for unit ${unitId} not initialized — call connect(host, port, ${unitId}) first`);
361
+ return reader;
362
+ }
363
+ /**
364
+ * Get (or create) the discovered-models map for a unit.
365
+ */
366
+ getModelsMap(unitId) {
367
+ let m = this.discoveredModelsByUnit.get(unitId);
368
+ if (!m) {
369
+ m = new Map();
370
+ this.discoveredModelsByUnit.set(unitId, m);
371
+ }
372
+ return m;
185
373
  }
186
374
  /**
187
375
  * Enable or disable automatic reconnection
@@ -199,16 +387,14 @@ class SunspecModbusClient {
199
387
  /**
200
388
  * Detect the base address and addressing mode (0-based or 1-based) for SunSpec
201
389
  */
202
- async detectSunspecBaseAddress(customBaseAddress) {
203
- if (!this.modbusClient) {
204
- throw new Error('Modbus client not initialized');
205
- }
390
+ async detectSunspecBaseAddress(unitId, customBaseAddress) {
391
+ const instance = this.getInstance(unitId);
206
392
  // If custom base address provided, try it first (1-based, then 0-based variant)
207
393
  if (customBaseAddress !== undefined) {
208
394
  console.log(`Detect models for custom base address '${customBaseAddress}' ...`);
209
395
  // Try 0-based at custom address
210
396
  try {
211
- const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress, 2);
397
+ const sunspecId = await instance.readRegisterStringValue(customBaseAddress, 2);
212
398
  if (sunspecId.includes('SunS')) {
213
399
  console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
214
400
  return {
@@ -223,7 +409,7 @@ class SunspecModbusClient {
223
409
  }
224
410
  // Try 1-based at custom address (customBaseAddress + 1)
225
411
  try {
226
- const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress + 1, 2);
412
+ const sunspecId = await instance.readRegisterStringValue(customBaseAddress + 1, 2);
227
413
  if (sunspecId.includes('SunS')) {
228
414
  console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
229
415
  return {
@@ -240,7 +426,7 @@ class SunspecModbusClient {
240
426
  else {
241
427
  // Try 1-based addressing first (most common)
242
428
  try {
243
- const sunspecId = await this.modbusClient.readRegisterStringValue(40001, 2);
429
+ const sunspecId = await instance.readRegisterStringValue(40001, 2);
244
430
  if (sunspecId.includes('SunS')) {
245
431
  console.log('Detected 1-based addressing mode (base address: 40001)');
246
432
  return {
@@ -255,7 +441,7 @@ class SunspecModbusClient {
255
441
  }
256
442
  // Try 0-based addressing
257
443
  try {
258
- const sunspecId = await this.modbusClient.readRegisterStringValue(40000, 2);
444
+ const sunspecId = await instance.readRegisterStringValue(40000, 2);
259
445
  if (sunspecId.includes('SunS')) {
260
446
  console.log('Detected 0-based addressing mode (base address: 40000)');
261
447
  return {
@@ -275,27 +461,22 @@ class SunspecModbusClient {
275
461
  throw new Error(`Device is not SunSpec compliant - "SunS" identifier not found at addresses ${addressesChecked}`);
276
462
  }
277
463
  /**
278
- * Discover all available Sunspec models
464
+ * Discover all available Sunspec models for a unit
279
465
  * Automatically detects base address (40000 or 40001) and scans from there
280
466
  */
281
- async discoverModels(customBaseAddress) {
282
- if (!this.connected) {
283
- throw new Error('Not connected to Modbus device');
284
- }
285
- this.discoveredModels.clear();
467
+ async discoverModels(unitId, customBaseAddress) {
468
+ const instance = this.getInstance(unitId);
469
+ const models = this.getModelsMap(unitId);
470
+ models.clear();
286
471
  const maxAddress = 50000; // Safety limit
287
472
  let currentAddress = 0;
288
- console.log('Starting Sunspec model discovery...');
473
+ console.log(`Starting Sunspec model discovery for unit ${unitId}...`);
289
474
  try {
290
475
  // Detect the base address and addressing mode
291
- const addressInfo = await this.detectSunspecBaseAddress(customBaseAddress);
476
+ const addressInfo = await this.detectSunspecBaseAddress(unitId, customBaseAddress);
292
477
  currentAddress = addressInfo.nextAddress;
293
478
  while (currentAddress < maxAddress) {
294
- // Read model ID and length
295
- if (!this.modbusClient) {
296
- throw new Error('Modbus client not initialized');
297
- }
298
- const buffer = await this.modbusClient.readHoldingRegisters(currentAddress, 2);
479
+ const buffer = await instance.readHoldingRegisters(currentAddress, 2);
299
480
  const modelData = [buffer.readUInt16BE(0), buffer.readUInt16BE(2)];
300
481
  if (!modelData || modelData.length < 2) {
301
482
  console.log(`No data at address ${currentAddress}, ending discovery`);
@@ -314,23 +495,23 @@ class SunspecModbusClient {
314
495
  address: currentAddress,
315
496
  length: modelLength
316
497
  };
317
- this.discoveredModels.set(modelId, model);
318
- console.log(`Discovered Model ${modelId} at address ${currentAddress} with length ${modelLength}`);
498
+ models.set(modelId, model);
499
+ console.log(`Discovered Model ${modelId} at address ${currentAddress} with length ${modelLength} (unit ${unitId})`);
319
500
  // Jump to next model: current address + 2 (header) + model length
320
501
  currentAddress = currentAddress + 2 + modelLength;
321
502
  }
322
503
  }
323
504
  catch (error) {
324
- console.error(`Error during model discovery at address ${currentAddress}: ${error}`);
505
+ console.error(`Error during model discovery at address ${currentAddress} (unit ${unitId}): ${error}`);
325
506
  }
326
- console.log(`Discovery complete. Found ${this.discoveredModels.size} models`);
327
- return this.discoveredModels;
507
+ console.log(`Discovery complete for unit ${unitId}. Found ${models.size} models`);
508
+ return models;
328
509
  }
329
510
  /**
330
- * Find a specific model by ID
511
+ * Find a specific model by ID for a given unit
331
512
  */
332
- findModel(modelId) {
333
- return this.discoveredModels.get(modelId);
513
+ findModel(unitId, modelId) {
514
+ return this.discoveredModelsByUnit.get(unitId)?.get(modelId);
334
515
  }
335
516
  /**
336
517
  * Check if a value is "unimplemented" according to Sunspec specification
@@ -406,17 +587,15 @@ class SunspecModbusClient {
406
587
  * Read an entire model's register block in a single Modbus call.
407
588
  * Returns a Buffer containing all registers for the model.
408
589
  */
409
- async readModelBlock(model) {
410
- if (!this.faultTolerantReader) {
411
- throw new Error('Fault-tolerant reader not initialized');
412
- }
590
+ async readModelBlock(unitId, model) {
591
+ const reader = this.getReader(unitId);
413
592
  // Read model.length + 2 registers: the 2-register header (ID + length) plus all data registers.
414
593
  // This way buffer offsets match the convention used throughout: offset 0 = model ID,
415
594
  // offset 1 = model length, offset 2 = first data register, etc.
416
595
  const totalRegisters = model.length + 2;
417
- const result = await this.faultTolerantReader.readHoldingRegisters(model.address, totalRegisters);
596
+ const result = await reader.readHoldingRegisters(model.address, totalRegisters);
418
597
  if (!result.success || !result.value) {
419
- throw new Error(`Failed to read model block ${model.id} at address ${model.address}: ${result.error?.message || 'Unknown error'}`);
598
+ throw new Error(`Failed to read model block ${model.id} at address ${model.address} (unit ${unitId}): ${result.error?.message || 'Unknown error'}`);
420
599
  }
421
600
  this.connectionHealth.recordSuccess();
422
601
  return result.value;
@@ -441,12 +620,10 @@ class SunspecModbusClient {
441
620
  /**
442
621
  * Helper to read register value(s) using the fault-tolerant reader with data type conversion
443
622
  */
444
- async readRegisterValue(address, quantity = 1, dataType) {
445
- if (!this.faultTolerantReader) {
446
- throw new Error('Fault-tolerant reader not initialized');
447
- }
623
+ async readRegisterValue(unitId, address, quantity = 1, dataType) {
624
+ const reader = this.getReader(unitId);
448
625
  try {
449
- const result = await this.faultTolerantReader.readHoldingRegisters(address, quantity);
626
+ const result = await reader.readHoldingRegisters(address, quantity);
450
627
  // Check if the read was successful
451
628
  if (!result.success || !result.value) {
452
629
  throw new Error(`Failed to read register at address ${address}: ${result.error?.message || 'Unknown error'}`);
@@ -467,10 +644,8 @@ class SunspecModbusClient {
467
644
  /**
468
645
  * Helper to write register value(s)
469
646
  */
470
- async writeRegisterValue(address, value, dataType = 'uint16') {
471
- if (!this.modbusClient) {
472
- throw new Error('Modbus client not initialized');
473
- }
647
+ async writeRegisterValue(unitId, address, value, dataType = 'uint16') {
648
+ const instance = this.getInstance(unitId);
474
649
  try {
475
650
  // Convert value to array of register values
476
651
  let registerValues;
@@ -499,9 +674,9 @@ class SunspecModbusClient {
499
674
  }
500
675
  }
501
676
  // Write to holding registers
502
- await this.modbusClient.writeMultipleRegisters(address, registerValues);
677
+ await instance.writeMultipleRegisters(address, registerValues);
503
678
  this.connectionHealth.recordSuccess();
504
- console.log(`Successfully wrote value ${value} to register ${address}`);
679
+ console.log(`Successfully wrote value ${value} to register ${address} (unit ${unitId})`);
505
680
  return true;
506
681
  }
507
682
  catch (error) {
@@ -513,23 +688,23 @@ class SunspecModbusClient {
513
688
  /**
514
689
  * Read inverter data from Model 101 (Single Phase) / Model 103 (Three Phase)
515
690
  */
516
- async readInverterData() {
517
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Inverter3Phase);
691
+ async readInverterData(unitId) {
692
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Inverter3Phase);
518
693
  if (!model) {
519
- console.log('Inverter model 103 not found, trying single phase model 101');
520
- const singlePhaseModel = this.findModel(sunspec_interfaces_js_1.SunspecModelId.InverterSinglePhase);
694
+ console.debug(`Inverter model 103 not found on unit ${unitId}, trying single phase model 101`);
695
+ const singlePhaseModel = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.InverterSinglePhase);
521
696
  if (!singlePhaseModel) {
522
- console.error('No inverter model found');
697
+ console.error(`No inverter model found on unit ${unitId}`);
523
698
  return null;
524
699
  }
525
700
  console.warn('IMPORTANT: Working with single-phase inverter, but 3-phase is expected!');
526
- return this.readSinglePhaseInverterData(singlePhaseModel);
701
+ return this.readSinglePhaseInverterData(unitId, singlePhaseModel);
527
702
  }
528
- console.log(`Found 3-phase inverter model 103 at address ${model.address} with length ${model.length}`);
703
+ console.debug(`Found 3-phase inverter model 103 at address ${model.address} with length ${model.length} (unit ${unitId})`);
529
704
  try {
530
705
  // Read entire model block in a single Modbus call
531
- console.log(`Reading Inverter Data from Model ${model.id} at base address: ${model.address}`);
532
- const buffer = await this.readModelBlock(model);
706
+ console.debug(`Reading Inverter Data from Model ${model.id} at base address: ${model.address} (unit ${unitId})`);
707
+ const buffer = await this.readModelBlock(unitId, model);
533
708
  // Extract all scale factors from buffer
534
709
  const scaleFactors = this.extractInverterScaleFactors(buffer);
535
710
  // Extract raw values from buffer
@@ -610,11 +785,11 @@ class SunspecModbusClient {
610
785
  /**
611
786
  * Read single phase inverter data (Model 101)
612
787
  */
613
- async readSinglePhaseInverterData(model) {
788
+ async readSinglePhaseInverterData(unitId, model) {
614
789
  try {
615
- console.log(`Reading Single-Phase Inverter Data from Model 101 at base address: ${model.address}`);
790
+ console.debug(`Reading Single-Phase Inverter Data from Model 101 at base address: ${model.address} (unit ${unitId})`);
616
791
  // Read entire model block in a single Modbus call
617
- const buffer = await this.readModelBlock(model);
792
+ const buffer = await this.readModelBlock(unitId, model);
618
793
  // Extract scale factors from buffer
619
794
  const scaleFactors = {
620
795
  A_SF: this.extractValue(buffer, 6, 'int16'),
@@ -763,18 +938,18 @@ class SunspecModbusClient {
763
938
  this.logRegisterRead(160, 5, 'DCWH_SF', scaleFactors.DCWH_SF, 'int16');
764
939
  return scaleFactors;
765
940
  }
766
- async readMPPTScaleFactors() {
767
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MPPT);
941
+ async readMPPTScaleFactors(unitId) {
942
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MPPT);
768
943
  if (!model) {
769
- console.log('MPPT model 160 not found');
944
+ console.debug(`MPPT model 160 not found on unit ${unitId}`);
770
945
  return null;
771
946
  }
772
947
  try {
773
- const buffer = await this.readModelBlock(model);
948
+ const buffer = await this.readModelBlock(unitId, model);
774
949
  return this.extractMPPTScaleFactors(buffer);
775
950
  }
776
951
  catch (error) {
777
- console.error(`Error reading MPPT scale factors: ${error}`);
952
+ console.error(`Error reading MPPT scale factors (unit ${unitId}): ${error}`);
778
953
  return null;
779
954
  }
780
955
  }
@@ -800,7 +975,7 @@ class SunspecModbusClient {
800
975
  this.isUnimplementedValue(dcCurrentRaw, 'uint16') &&
801
976
  this.isUnimplementedValue(dcVoltageRaw, 'uint16') &&
802
977
  this.isUnimplementedValue(dcPowerRaw, 'uint16')) {
803
- console.log(`MPPT module ${moduleId} appears to be unconnected (all values are 0xFFFF)`);
978
+ console.debug(`MPPT module ${moduleId} appears to be unconnected (all values are 0xFFFF)`);
804
979
  return null;
805
980
  }
806
981
  const temperatureScaleFactor = -1;
@@ -835,52 +1010,52 @@ class SunspecModbusClient {
835
1010
  /**
836
1011
  * Read MPPT data from Model 160
837
1012
  */
838
- async readMPPTData(moduleId = 1) {
839
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MPPT);
1013
+ async readMPPTData(unitId, moduleId = 1) {
1014
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MPPT);
840
1015
  if (!model) {
841
- console.log('MPPT model 160 not found');
1016
+ console.debug(`MPPT model 160 not found on unit ${unitId}`);
842
1017
  return null;
843
1018
  }
844
1019
  try {
845
1020
  // Read entire model block in a single Modbus call
846
- const buffer = await this.readModelBlock(model);
1021
+ const buffer = await this.readModelBlock(unitId, model);
847
1022
  const scaleFactors = this.extractMPPTScaleFactors(buffer);
848
1023
  return this.extractMPPTModuleData(buffer, model, moduleId, scaleFactors);
849
1024
  }
850
1025
  catch (error) {
851
- console.error(`Error reading MPPT data for module ${moduleId}: ${error}`);
1026
+ console.error(`Error reading MPPT data for module ${moduleId} (unit ${unitId}): ${error}`);
852
1027
  return null;
853
1028
  }
854
1029
  }
855
1030
  /**
856
1031
  * Read all MPPT strings from Model 160 (Multiple MPPT)
857
1032
  */
858
- async readAllMPPTData() {
1033
+ async readAllMPPTData(unitId) {
859
1034
  const mpptData = [];
860
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MPPT);
1035
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MPPT);
861
1036
  if (!model) {
862
- console.log('MPPT model 160 not found');
1037
+ console.debug(`MPPT model 160 not found on unit ${unitId}`);
863
1038
  return [];
864
1039
  }
865
1040
  try {
866
1041
  // Read entire model block in a single Modbus call
867
- const buffer = await this.readModelBlock(model);
1042
+ const buffer = await this.readModelBlock(unitId, model);
868
1043
  const scaleFactors = this.extractMPPTScaleFactors(buffer);
869
1044
  // Read the module count from register 8
870
1045
  let moduleCount = 4; // Default fallback value
871
1046
  const count = this.extractValue(buffer, 8, 'uint16');
872
1047
  if (!this.isUnimplementedValue(count, 'uint16') && count > 0 && count <= 20) {
873
1048
  moduleCount = count;
874
- console.log(`MPPT module count from register 8: ${moduleCount}`);
1049
+ console.debug(`MPPT module count from register 8: ${moduleCount}`);
875
1050
  }
876
1051
  else {
877
- console.log(`Invalid or unimplemented module count (${count}), using default: ${moduleCount}`);
1052
+ console.debug(`Invalid or unimplemented module count (${count}), using default: ${moduleCount}`);
878
1053
  }
879
1054
  // Extract each MPPT module from the same buffer
880
1055
  for (let i = 1; i <= moduleCount; i++) {
881
1056
  try {
882
1057
  const data = this.extractMPPTModuleData(buffer, model, i, scaleFactors);
883
- console.log(`MPPT ${i} has id ${data?.id} (${data?.stringId}) with ${data?.dcPower}W`);
1058
+ console.debug(`MPPT ${i} has id ${data?.id} (${data?.stringId}) with ${data?.dcPower}W`);
884
1059
  if (data &&
885
1060
  (data.dcCurrent !== undefined ||
886
1061
  data.dcVoltage !== undefined ||
@@ -1025,16 +1200,16 @@ class SunspecModbusClient {
1025
1200
  /**
1026
1201
  * Read battery base data from Model 802 (Battery Base)
1027
1202
  */
1028
- async readBatteryBaseData() {
1029
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
1203
+ async readBatteryBaseData(unitId) {
1204
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
1030
1205
  if (!model) {
1031
- console.log('Battery Base model 802 not found');
1206
+ console.debug(`Battery Base model 802 not found on unit ${unitId}`);
1032
1207
  return null;
1033
1208
  }
1034
- console.log(`Reading Battery Base Data from Model 802 at base address: ${model.address}`);
1209
+ console.debug(`Reading Battery Base Data from Model 802 at base address: ${model.address} (unit ${unitId})`);
1035
1210
  try {
1036
1211
  // Read entire model block in a single Modbus call
1037
- const buffer = await this.readModelBlock(model);
1212
+ const buffer = await this.readModelBlock(unitId, model);
1038
1213
  // Extract scale factors from buffer (offsets 52-63)
1039
1214
  const sf = this.extractBatteryBaseScaleFactors(buffer);
1040
1215
  // Extract raw values from buffer
@@ -1163,27 +1338,27 @@ class SunspecModbusClient {
1163
1338
  /**
1164
1339
  * Read battery data from Model 124 (Basic Storage) with fallback to Model 802 / Model 803
1165
1340
  */
1166
- async readBatteryData() {
1341
+ async readBatteryData(unitId) {
1167
1342
  // Try Model 124 first (Basic Storage Controls)
1168
- let model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Battery);
1343
+ let model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Battery);
1169
1344
  // Fall back to other battery models if needed
1170
1345
  if (!model) {
1171
- model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
1346
+ model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
1172
1347
  }
1173
1348
  if (!model) {
1174
- model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.BatteryControl);
1349
+ model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.BatteryControl);
1175
1350
  }
1176
1351
  if (!model) {
1177
- console.log('No battery model found');
1352
+ console.debug(`No battery model found on unit ${unitId}`);
1178
1353
  return null;
1179
1354
  }
1180
- console.log(`Reading Battery Data from Model ${model.id} at base address: ${model.address}`);
1355
+ console.debug(`Reading Battery Data from Model ${model.id} at base address: ${model.address} (unit ${unitId})`);
1181
1356
  try {
1182
1357
  if (model.id === 124) {
1183
1358
  // Model 124: Basic Storage Controls
1184
- console.log('Using Model 124 (Basic Storage Controls)');
1359
+ console.debug('Using Model 124 (Basic Storage Controls)');
1185
1360
  // Read entire model block in a single Modbus call
1186
- const buffer = await this.readModelBlock(model);
1361
+ const buffer = await this.readModelBlock(unitId, model);
1187
1362
  // Extract scale factors from buffer (offsets 18-25)
1188
1363
  const scaleFactors = {
1189
1364
  WChaMax_SF: this.extractValue(buffer, 18, 'int16'),
@@ -1271,20 +1446,20 @@ class SunspecModbusClient {
1271
1446
  // Calculate charge/discharge power if rates are available
1272
1447
  if (data.inWRte !== undefined && data.wChaMax !== undefined) {
1273
1448
  data.chargePower = (data.inWRte / 100) * data.wChaMax;
1274
- console.log(`Calculated Charge Power: (inWRte: ${data.inWRte}, wChaMax: ${data.wChaMax}) ${data.chargePower?.toFixed(2)} W`);
1449
+ console.debug(`Calculated Charge Power: (inWRte: ${data.inWRte}, wChaMax: ${data.wChaMax}) ${data.chargePower?.toFixed(2)} W`);
1275
1450
  }
1276
1451
  if (data.outWRte !== undefined && data.wChaMax !== undefined) {
1277
1452
  // Assuming WDisChaMax is similar to WChaMax for simplicity
1278
1453
  data.dischargePower = Math.abs((data.outWRte / 100) * data.wChaMax);
1279
- console.log(`Calculated Discharge Power (inWRte: ${data.outWRte}, wChaMax: ${data.wChaMax}): ${data.dischargePower?.toFixed(2)} W`);
1454
+ console.debug(`Calculated Discharge Power (inWRte: ${data.outWRte}, wChaMax: ${data.wChaMax}): ${data.dischargePower?.toFixed(2)} W`);
1280
1455
  }
1281
1456
  console.debug('[Model 124] Battery Data:', data);
1282
1457
  return data;
1283
1458
  }
1284
1459
  else if (model.id === 802) {
1285
1460
  // Model 802: Battery Base
1286
- console.log('Using Model 802 (Battery Base)');
1287
- const baseData = await this.readBatteryBaseData();
1461
+ console.debug('Using Model 802 (Battery Base)');
1462
+ const baseData = await this.readBatteryBaseData(unitId);
1288
1463
  if (!baseData) {
1289
1464
  return null;
1290
1465
  }
@@ -1320,7 +1495,7 @@ class SunspecModbusClient {
1320
1495
  }
1321
1496
  else {
1322
1497
  // Handle other battery models (803) if needed
1323
- console.log(`Battery Model ${model.id} reading not yet implemented`);
1498
+ console.debug(`Battery Model ${model.id} reading not yet implemented`);
1324
1499
  return {
1325
1500
  blockNumber: model.id,
1326
1501
  blockAddress: model.address,
@@ -1336,55 +1511,55 @@ class SunspecModbusClient {
1336
1511
  /**
1337
1512
  * Write battery control settings to Model 124
1338
1513
  */
1339
- async writeBatteryControls(controls) {
1340
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Battery);
1514
+ async writeBatteryControls(unitId, controls) {
1515
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Battery);
1341
1516
  if (!model) {
1342
- console.error('Battery model 124 not found');
1517
+ console.error(`Battery model 124 not found on unit ${unitId}`);
1343
1518
  return false;
1344
1519
  }
1345
1520
  const baseAddr = model.address;
1346
- console.log(`Writing Battery Controls to Model 124 at base address: ${baseAddr}`);
1521
+ console.log(`Writing Battery Controls to Model 124 at base address: ${baseAddr} (unit ${unitId})`);
1347
1522
  try {
1348
1523
  // Write storage control mode (Register 5)
1349
1524
  if (controls.storCtlMod !== undefined) {
1350
- await this.writeRegisterValue(baseAddr + 5, controls.storCtlMod, 'uint16');
1525
+ await this.writeRegisterValue(unitId, baseAddr + 5, controls.storCtlMod, 'uint16');
1351
1526
  console.log(`Set storage control mode to 0x${controls.storCtlMod.toString(16)}`);
1352
1527
  }
1353
1528
  // Write charge source setting (Register 17)
1354
1529
  if (controls.chaGriSet !== undefined) {
1355
- await this.writeRegisterValue(baseAddr + 17, controls.chaGriSet, 'uint16');
1530
+ await this.writeRegisterValue(unitId, baseAddr + 17, controls.chaGriSet, 'uint16');
1356
1531
  console.log(`Set charge source to ${controls.chaGriSet === sunspec_interfaces_js_1.SunspecChargeSource.GRID ? 'GRID' : 'PV'}`);
1357
1532
  }
1358
1533
  // Write maximum charge power (Register 2) - needs scale factor
1359
1534
  if (controls.wChaMax !== undefined) {
1360
1535
  const scaleFactorAddr = baseAddr + 18;
1361
- const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
1536
+ const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
1362
1537
  const scaledValue = Math.round(controls.wChaMax / Math.pow(10, scaleFactor));
1363
- await this.writeRegisterValue(baseAddr + 2, scaledValue, 'uint16');
1538
+ await this.writeRegisterValue(unitId, baseAddr + 2, scaledValue, 'uint16');
1364
1539
  console.log(`Set max charge power to ${controls.wChaMax}W (scaled: ${scaledValue})`);
1365
1540
  }
1366
1541
  // Write charge rate (Register 13) - needs scale factor
1367
1542
  if (controls.inWRte !== undefined) {
1368
1543
  const scaleFactorAddr = baseAddr + 25;
1369
- const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
1544
+ const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
1370
1545
  const scaledValue = Math.round(controls.inWRte / Math.pow(10, scaleFactor));
1371
- await this.writeRegisterValue(baseAddr + 13, scaledValue, 'int16');
1546
+ await this.writeRegisterValue(unitId, baseAddr + 13, scaledValue, 'int16');
1372
1547
  console.log(`Set charge rate to ${controls.inWRte}% (scaled: ${scaledValue})`);
1373
1548
  }
1374
1549
  // Write discharge rate (Register 12) - needs scale factor
1375
1550
  if (controls.outWRte !== undefined) {
1376
1551
  const scaleFactorAddr = baseAddr + 25;
1377
- const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
1552
+ const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
1378
1553
  const scaledValue = Math.round(controls.outWRte / Math.pow(10, scaleFactor));
1379
- await this.writeRegisterValue(baseAddr + 12, scaledValue, 'int16');
1554
+ await this.writeRegisterValue(unitId, baseAddr + 12, scaledValue, 'int16');
1380
1555
  console.log(`Set discharge rate to ${controls.outWRte}% (scaled: ${scaledValue})`);
1381
1556
  }
1382
1557
  // Write minimum reserve percentage (Register 7) - needs scale factor
1383
1558
  if (controls.minRsvPct !== undefined) {
1384
1559
  const scaleFactorAddr = baseAddr + 21;
1385
- const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
1560
+ const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
1386
1561
  const scaledValue = Math.round(controls.minRsvPct / Math.pow(10, scaleFactor));
1387
- await this.writeRegisterValue(baseAddr + 7, scaledValue, 'uint16');
1562
+ await this.writeRegisterValue(unitId, baseAddr + 7, scaledValue, 'uint16');
1388
1563
  console.log(`Set minimum reserve to ${controls.minRsvPct}% (scaled: ${scaledValue})`);
1389
1564
  }
1390
1565
  console.log('Battery controls written successfully');
@@ -1398,7 +1573,7 @@ class SunspecModbusClient {
1398
1573
  /**
1399
1574
  * Set battery storage mode (simplified interface)
1400
1575
  */
1401
- async setStorageMode(mode) {
1576
+ async setStorageMode(unitId, mode) {
1402
1577
  let storCtlMod;
1403
1578
  switch (mode) {
1404
1579
  case sunspec_interfaces_js_1.SunspecStorageMode.CHARGE:
@@ -1422,29 +1597,29 @@ class SunspecModbusClient {
1422
1597
  return false;
1423
1598
  }
1424
1599
  console.log(`Setting storage mode to ${mode} (control bits: 0x${storCtlMod.toString(16)})`);
1425
- return this.writeBatteryControls({ storCtlMod });
1600
+ return this.writeBatteryControls(unitId, { storCtlMod });
1426
1601
  }
1427
1602
  /**
1428
1603
  * Enable or disable grid charging
1429
1604
  */
1430
- async enableGridCharging(enable) {
1605
+ async enableGridCharging(unitId, enable) {
1431
1606
  const chaGriSet = enable ? sunspec_interfaces_js_1.SunspecChargeSource.GRID : sunspec_interfaces_js_1.SunspecChargeSource.PV;
1432
- console.log(`${enable ? 'Enabling' : 'Disabling'} grid charging`);
1433
- return this.writeBatteryControls({ chaGriSet });
1607
+ console.log(`${enable ? 'Enabling' : 'Disabling'} grid charging on unit ${unitId}`);
1608
+ return this.writeBatteryControls(unitId, { chaGriSet });
1434
1609
  }
1435
1610
  /**
1436
1611
  * Read battery control settings from Model 124 (Basic Storage Controls)
1437
1612
  */
1438
- async readBatteryControls() {
1439
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Battery);
1613
+ async readBatteryControls(unitId) {
1614
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Battery);
1440
1615
  if (!model) {
1441
- console.log('Battery model 124 not found');
1616
+ console.log(`Battery model 124 not found on unit ${unitId}`);
1442
1617
  return null;
1443
1618
  }
1444
- console.log(`Reading Battery Controls from Model 124 at base address: ${model.address}`);
1619
+ console.log(`Reading Battery Controls from Model 124 at base address: ${model.address} (unit ${unitId})`);
1445
1620
  try {
1446
1621
  // Read entire model block in a single Modbus call
1447
- const buffer = await this.readModelBlock(model);
1622
+ const buffer = await this.readModelBlock(unitId, model);
1448
1623
  // Extract scale factors from buffer
1449
1624
  const scaleFactors = {
1450
1625
  WChaMax_SF: this.extractValue(buffer, 18, 'int16'),
@@ -1483,22 +1658,22 @@ class SunspecModbusClient {
1483
1658
  /**
1484
1659
  * Read meter data from Model 201 (Single Phase) / Model 203 (Three Phase) / Model 204 (Split Phase)
1485
1660
  */
1486
- async readMeterData() {
1487
- let model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Meter3Phase);
1661
+ async readMeterData(unitId) {
1662
+ let model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Meter3Phase);
1488
1663
  if (!model) {
1489
- model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MeterWye);
1664
+ model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MeterWye);
1490
1665
  }
1491
1666
  if (!model) {
1492
- model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MeterSinglePhase);
1667
+ model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MeterSinglePhase);
1493
1668
  }
1494
1669
  if (!model) {
1495
- console.log('No meter model found');
1670
+ console.debug(`No meter model found on unit ${unitId}`);
1496
1671
  return null;
1497
1672
  }
1498
- console.log(`Reading Meter Data from Model ${model.id} at base address: ${model.address}`);
1673
+ console.debug(`Reading Meter Data from Model ${model.id} at base address: ${model.address} (unit ${unitId})`);
1499
1674
  try {
1500
1675
  // Different meter models have different register offsets
1501
- console.log(`Meter is Model ${model.id}`);
1676
+ console.debug(`Meter is Model ${model.id}`);
1502
1677
  let powerOffset;
1503
1678
  let powerSFOffset;
1504
1679
  let freqOffset;
@@ -1508,7 +1683,7 @@ class SunspecModbusClient {
1508
1683
  let energySFOffset;
1509
1684
  switch (model.id) {
1510
1685
  case 203: // 3-phase meter (Delta)
1511
- console.log('Using Model 203 (3-Phase Meter Delta) offsets');
1686
+ console.debug('Using Model 203 (3-Phase Meter Delta) offsets');
1512
1687
  powerOffset = 18; // W - Total Real Power (int16)
1513
1688
  powerSFOffset = 22; // W_SF - Power scale factor
1514
1689
  freqOffset = 16; // Hz - Frequency (uint16)
@@ -1518,7 +1693,7 @@ class SunspecModbusClient {
1518
1693
  energySFOffset = 54; // TotWh_SF - Total Energy scale factor
1519
1694
  break;
1520
1695
  case 204: // 3-phase meter (Wye)
1521
- console.log('Using Model 204 (3-Phase Meter Wye) offsets');
1696
+ console.debug('Using Model 204 (3-Phase Meter Wye) offsets');
1522
1697
  powerOffset = 18; // W - Total Real Power (int16)
1523
1698
  powerSFOffset = 22; // W_SF - Power scale factor
1524
1699
  freqOffset = 16; // Hz - Frequency (uint16)
@@ -1529,7 +1704,7 @@ class SunspecModbusClient {
1529
1704
  break;
1530
1705
  case 201: // Single-phase meter
1531
1706
  default:
1532
- console.log('Using Model 201 (Single-Phase Meter) offsets');
1707
+ console.debug('Using Model 201 (Single-Phase Meter) offsets');
1533
1708
  powerOffset = 18; // W - Total Real Power (int16)
1534
1709
  powerSFOffset = 22; // W_SF - Power scale factor
1535
1710
  freqOffset = 16; // Hz - Frequency (uint16)
@@ -1539,7 +1714,7 @@ class SunspecModbusClient {
1539
1714
  energySFOffset = 54; // TotWh_SF - Total Energy scale factor
1540
1715
  }
1541
1716
  // Read entire model block in a single Modbus call
1542
- const buffer = await this.readModelBlock(model);
1717
+ const buffer = await this.readModelBlock(unitId, model);
1543
1718
  // Extract scale factors from buffer
1544
1719
  const powerSF = this.extractValue(buffer, powerSFOffset, 'int16');
1545
1720
  const freqSF = this.extractValue(buffer, freqSFOffset, 'int16');
@@ -1577,16 +1752,16 @@ class SunspecModbusClient {
1577
1752
  /**
1578
1753
  * Read common block data (Model 1)
1579
1754
  */
1580
- async readCommonBlock() {
1581
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Common);
1755
+ async readCommonBlock(unitId) {
1756
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Common);
1582
1757
  if (!model) {
1583
- console.error('Common block model not found');
1758
+ console.error(`Common block model not found on unit ${unitId}`);
1584
1759
  return null;
1585
1760
  }
1586
- console.log(`Reading Common Block - Model address: ${model.address}`);
1761
+ console.log(`Reading Common Block - Model address: ${model.address} (unit ${unitId})`);
1587
1762
  try {
1588
1763
  // Read entire model block in a single Modbus call
1589
- const buffer = await this.readModelBlock(model);
1764
+ const buffer = await this.readModelBlock(unitId, model);
1590
1765
  // Common block offsets are relative to the model start (after ID and Length header,
1591
1766
  // but readModelBlock reads from model.address which includes the data area).
1592
1767
  // The offsets below are relative to the data start within the model block.
@@ -1623,24 +1798,24 @@ class SunspecModbusClient {
1623
1798
  /**
1624
1799
  * Get serial number from device
1625
1800
  */
1626
- async getSerialNumber() {
1627
- const commonData = await this.readCommonBlock();
1801
+ async getSerialNumber(unitId) {
1802
+ const commonData = await this.readCommonBlock(unitId);
1628
1803
  return commonData?.serialNumber;
1629
1804
  }
1630
1805
  /**
1631
- * Check if connected
1806
+ * Check if a specific unit is connected on this network device
1632
1807
  */
1633
- isConnected() {
1634
- return this.connected;
1808
+ isConnected(unitId) {
1809
+ return this.modbusInstances.has(unitId);
1635
1810
  }
1636
1811
  /**
1637
- * Check if connection is healthy
1812
+ * Check if a specific unit's connection is healthy
1638
1813
  */
1639
- isHealthy() {
1640
- return this.connected && this.connectionHealth.isHealthy();
1814
+ isHealthy(unitId) {
1815
+ return this.modbusInstances.has(unitId) && this.connectionHealth.isHealthy();
1641
1816
  }
1642
1817
  /**
1643
- * Get connection health details
1818
+ * Get connection health details (shared across all units on this network device)
1644
1819
  */
1645
1820
  getConnectionHealth() {
1646
1821
  return this.connectionHealth;
@@ -1648,15 +1823,15 @@ class SunspecModbusClient {
1648
1823
  /**
1649
1824
  * Read inverter settings from Model 121 (Inverter Settings)
1650
1825
  */
1651
- async readInverterSettings() {
1652
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Settings);
1826
+ async readInverterSettings(unitId) {
1827
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Settings);
1653
1828
  if (!model) {
1654
- console.log('Settings model 121 not found');
1829
+ console.debug(`Settings model 121 not found on unit ${unitId}`);
1655
1830
  return null;
1656
1831
  }
1657
1832
  try {
1658
1833
  // Read entire model block in a single Modbus call
1659
- const buffer = await this.readModelBlock(model);
1834
+ const buffer = await this.readModelBlock(unitId, model);
1660
1835
  // Extract scale factors from buffer (offsets 22-31)
1661
1836
  const scaleFactors = {
1662
1837
  WMax_SF: this.extractValue(buffer, 22, 'int16'),
@@ -1740,15 +1915,15 @@ class SunspecModbusClient {
1740
1915
  /**
1741
1916
  * Read inverter controls from Model 123 (Immediate Inverter Controls)
1742
1917
  */
1743
- async readInverterControls() {
1744
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Controls);
1918
+ async readInverterControls(unitId) {
1919
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Controls);
1745
1920
  if (!model) {
1746
- console.log('Controls model 123 not found');
1921
+ console.log(`Controls model 123 not found on unit ${unitId}`);
1747
1922
  return null;
1748
1923
  }
1749
1924
  try {
1750
1925
  // Read entire model block in a single Modbus call
1751
- const buffer = await this.readModelBlock(model);
1926
+ const buffer = await this.readModelBlock(unitId, model);
1752
1927
  // Extract scale factors from buffer (offsets 21-23)
1753
1928
  const scaleFactors = {
1754
1929
  WMaxLimPct_SF: this.extractValue(buffer, 21, 'int16'),
@@ -1835,27 +2010,28 @@ class SunspecModbusClient {
1835
2010
  /**
1836
2011
  * Write Block 121 - Inverter Basic Settings
1837
2012
  */
1838
- async writeInverterSettings(settings) {
1839
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Settings);
2013
+ async writeInverterSettings(unitId, settings) {
2014
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Settings);
1840
2015
  if (!model) {
1841
- console.error('Settings model 121 not found');
2016
+ console.error(`Settings model 121 not found on unit ${unitId}`);
1842
2017
  return false;
1843
2018
  }
1844
2019
  const baseAddr = model.address;
1845
2020
  try {
2021
+ const instance = this.getInstance(unitId);
1846
2022
  // For each setting, write the value if provided
1847
2023
  // Note: This is a simplified implementation. In production, you'd batch writes
1848
- if (settings.WMax !== undefined && this.modbusClient) {
2024
+ if (settings.WMax !== undefined) {
1849
2025
  // Need to read scale factor first if not provided
1850
- const sfBuffer = await this.modbusClient.readHoldingRegisters(baseAddr + 22, 1);
2026
+ const sfBuffer = await instance.readHoldingRegisters(baseAddr + 22, 1);
1851
2027
  const scaleFactor = sfBuffer.readInt16BE(0);
1852
2028
  const scaledValue = Math.round(settings.WMax / Math.pow(10, scaleFactor));
1853
2029
  // Writing registers needs to be implemented in EnergyAppModbusInstance
1854
2030
  // For now, log the write operation
1855
2031
  console.log(`Would write value ${scaledValue} to register ${baseAddr}`);
1856
2032
  }
1857
- if (settings.VRef !== undefined && this.modbusClient) {
1858
- const sfBuffer = await this.modbusClient.readHoldingRegisters(baseAddr + 23, 1);
2033
+ if (settings.VRef !== undefined) {
2034
+ const sfBuffer = await instance.readHoldingRegisters(baseAddr + 23, 1);
1859
2035
  const scaleFactor = sfBuffer.readInt16BE(0);
1860
2036
  const scaledValue = Math.round(settings.VRef / Math.pow(10, scaleFactor));
1861
2037
  console.log(`Would write value ${scaledValue} to register ${baseAddr + 1}`);
@@ -1872,41 +2048,41 @@ class SunspecModbusClient {
1872
2048
  /**
1873
2049
  * Write inverter controls to Model 123 (Immediate Inverter Controls)
1874
2050
  */
1875
- async writeInverterControls(controls) {
1876
- const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Controls);
2051
+ async writeInverterControls(unitId, controls) {
2052
+ const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Controls);
1877
2053
  if (!model) {
1878
- console.error('Controls model 123 not found');
2054
+ console.error(`Controls model 123 not found on unit ${unitId}`);
1879
2055
  return false;
1880
2056
  }
1881
2057
  const baseAddr = model.address;
1882
2058
  try {
1883
2059
  // Connection control (Register 2)
1884
2060
  if (controls.Conn !== undefined) {
1885
- await this.writeRegisterValue(baseAddr + 2, controls.Conn, 'uint16');
2061
+ await this.writeRegisterValue(unitId, baseAddr + 2, controls.Conn, 'uint16');
1886
2062
  console.log(`Set connection control to ${controls.Conn}`);
1887
2063
  }
1888
2064
  // Power limit control (Register 3) - needs scale factor
1889
2065
  if (controls.WMaxLimPct !== undefined) {
1890
- const scaleFactor = await this.readRegisterValue(baseAddr + 21, 1, 'int16');
2066
+ const scaleFactor = await this.readRegisterValue(unitId, baseAddr + 21, 1, 'int16');
1891
2067
  const scaledValue = Math.round(controls.WMaxLimPct / Math.pow(10, scaleFactor));
1892
- await this.writeRegisterValue(baseAddr + 3, scaledValue, 'uint16');
2068
+ await this.writeRegisterValue(unitId, baseAddr + 3, scaledValue, 'uint16');
1893
2069
  console.log(`Set power limit to ${controls.WMaxLimPct}% (scaled: ${scaledValue})`);
1894
2070
  }
1895
2071
  // Throttle enable/disable (Register 7)
1896
2072
  if (controls.WMaxLim_Ena !== undefined) {
1897
- await this.writeRegisterValue(baseAddr + 7, controls.WMaxLim_Ena, 'uint16');
2073
+ await this.writeRegisterValue(unitId, baseAddr + 7, controls.WMaxLim_Ena, 'uint16');
1898
2074
  console.log(`Set throttle enable to ${controls.WMaxLim_Ena}`);
1899
2075
  }
1900
2076
  // Power factor control (Register 8) - needs scale factor
1901
2077
  if (controls.OutPFSet !== undefined) {
1902
- const scaleFactor = await this.readRegisterValue(baseAddr + 22, 1, 'int16');
2078
+ const scaleFactor = await this.readRegisterValue(unitId, baseAddr + 22, 1, 'int16');
1903
2079
  const scaledValue = Math.round(controls.OutPFSet / Math.pow(10, scaleFactor));
1904
- await this.writeRegisterValue(baseAddr + 8, scaledValue, 'int16');
2080
+ await this.writeRegisterValue(unitId, baseAddr + 8, scaledValue, 'int16');
1905
2081
  console.log(`Set power factor to ${controls.OutPFSet} (scaled: ${scaledValue})`);
1906
2082
  }
1907
2083
  // Power factor enable/disable (Register 12)
1908
2084
  if (controls.OutPFSet_Ena !== undefined) {
1909
- await this.writeRegisterValue(baseAddr + 12, controls.OutPFSet_Ena, 'uint16');
2085
+ await this.writeRegisterValue(unitId, baseAddr + 12, controls.OutPFSet_Ena, 'uint16');
1910
2086
  console.log(`Set PF enable to ${controls.OutPFSet_Ena}`);
1911
2087
  }
1912
2088
  console.log('Inverter controls written successfully');
@@ -1927,21 +2103,21 @@ class SunspecModbusClient {
1927
2103
  * @param limitW - Power limit in Watts, or null to remove the limit
1928
2104
  * @returns true if successful, false otherwise
1929
2105
  */
1930
- async setFeedInLimit(limitW) {
2106
+ async setFeedInLimit(unitId, limitW) {
1931
2107
  if (limitW === null) {
1932
2108
  // Remove limit: disable WMaxLim_Ena
1933
- console.log('Removing feed-in limit (disabling WMaxLim_Ena)');
1934
- return this.writeInverterControls({ WMaxLim_Ena: sunspec_interfaces_js_1.SunspecEnableControl.DISABLED });
2109
+ console.log(`Removing feed-in limit (disabling WMaxLim_Ena) on unit ${unitId}`);
2110
+ return this.writeInverterControls(unitId, { WMaxLim_Ena: sunspec_interfaces_js_1.SunspecEnableControl.DISABLED });
1935
2111
  }
1936
2112
  // Read WMax from Model 121 to compute percentage
1937
- const settings = await this.readInverterSettings();
2113
+ const settings = await this.readInverterSettings(unitId);
1938
2114
  if (!settings || !settings.WMax) {
1939
- console.error('Cannot set feed-in limit: unable to read WMax from Model 121');
2115
+ console.error(`Cannot set feed-in limit on unit ${unitId}: unable to read WMax from Model 121`);
1940
2116
  return false;
1941
2117
  }
1942
2118
  const pct = (limitW / settings.WMax) * 100;
1943
- console.log(`Setting feed-in limit to ${limitW}W (${pct.toFixed(2)}% of WMax ${settings.WMax}W)`);
1944
- return this.writeInverterControls({
2119
+ console.log(`Setting feed-in limit to ${limitW}W (${pct.toFixed(2)}% of WMax ${settings.WMax}W) on unit ${unitId}`);
2120
+ return this.writeInverterControls(unitId, {
1945
2121
  WMaxLimPct: pct,
1946
2122
  WMaxLim_Ena: sunspec_interfaces_js_1.SunspecEnableControl.ENABLED
1947
2123
  });