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